Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports: - Custom image upload (or fall back to Lucide icon) via the existing ServiceImageUploader; rendered on the home launcher when set. - Open mode picker: internal (default), external_tab (window.open), external_iframe (renders inside /embedded/:id). External URL input shown conditionally and required by the form when an external mode is chosen. - Route field is locked (readOnly + lock hint) when editing a built-in app, since those slugs are hardcoded in the SPA router. Backend: - apps schema gains image_url, external_url, open_mode (default 'internal'); drizzle-kit push applied. - New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS + isBuiltinAppSlug, re-exported from lib/db. - PATCH /apps/:id rejects route changes whose previous slug is built-in with 400 + code='builtin_route_locked'. Same-route no-op is allowed; non-route updates on built-ins still work. Other: - New SPA route /embedded/:id and embedded-app page (iframe host with back + open-in-new-tab + error/not-embeddable states). - OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran. - en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*, builtinPathLocked, embeddedFrame.*. - New tests in apps-builtin-route-lock.test.mjs (4/4 pass) covering reject built-in route change, allow non-route built-in updates, allow non-builtin route changes, allow built-in same-route no-op. Notes / drift: - BUILTIN_APP_SLUGS is duplicated inline in admin.tsx (BUILTIN_APP_SLUGS_FE) because the browser bundle cannot import @workspace/db (pulls pg). Comment points at the canonical source; drift risk filed as a follow-up. - Pre-existing failures unrelated to this task: 3 tests in executive-meetings-* and tsc errors in api-server/src/routes/executive-meetings.ts. Out of scope.
This commit is contained in:
@@ -562,7 +562,24 @@ export const ListAppsResponseItem = zod.object({
|
||||
descriptionAr: zod.string().nullish(),
|
||||
descriptionEn: zod.string().nullish(),
|
||||
iconName: zod.string(),
|
||||
imageUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Optional uploaded image used in place of the Lucide icon on the\nlauncher tile. Stored as the object-storage path returned by the\nupload presigner (e.g. \"\/objects\/<id>\").\n',
|
||||
),
|
||||
route: zod.string(),
|
||||
externalUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'For openMode \"external_tab\" or \"external_iframe\": the URL the\nlauncher should open. Ignored when openMode is \"internal\".\n',
|
||||
),
|
||||
openMode: zod
|
||||
.enum(["internal", "external_tab", "external_iframe"])
|
||||
.describe(
|
||||
'How the launcher opens this app: \"internal\" navigates to `route`\ninside the SPA; \"external_tab\" opens externalUrl in a new tab;\n\"external_iframe\" navigates to \/embedded\/:id, which renders\nexternalUrl inside an iframe shell.\n',
|
||||
),
|
||||
color: zod.string(),
|
||||
isActive: zod.boolean(),
|
||||
isSystem: zod.boolean(),
|
||||
@@ -600,7 +617,12 @@ export const CreateAppBody = zod.object({
|
||||
descriptionAr: zod.string().nullish(),
|
||||
descriptionEn: zod.string().nullish(),
|
||||
iconName: zod.string(),
|
||||
imageUrl: zod.string().nullish(),
|
||||
route: zod.string(),
|
||||
externalUrl: zod.string().nullish(),
|
||||
openMode: zod
|
||||
.enum(["internal", "external_tab", "external_iframe"])
|
||||
.optional(),
|
||||
color: zod.string(),
|
||||
isActive: zod.boolean().optional(),
|
||||
isSystem: zod.boolean().optional(),
|
||||
@@ -628,7 +650,24 @@ export const GetAppResponse = zod.object({
|
||||
descriptionAr: zod.string().nullish(),
|
||||
descriptionEn: zod.string().nullish(),
|
||||
iconName: zod.string(),
|
||||
imageUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Optional uploaded image used in place of the Lucide icon on the\nlauncher tile. Stored as the object-storage path returned by the\nupload presigner (e.g. \"\/objects\/<id>\").\n',
|
||||
),
|
||||
route: zod.string(),
|
||||
externalUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'For openMode \"external_tab\" or \"external_iframe\": the URL the\nlauncher should open. Ignored when openMode is \"internal\".\n',
|
||||
),
|
||||
openMode: zod
|
||||
.enum(["internal", "external_tab", "external_iframe"])
|
||||
.describe(
|
||||
'How the launcher opens this app: \"internal\" navigates to `route`\ninside the SPA; \"external_tab\" opens externalUrl in a new tab;\n\"external_iframe\" navigates to \/embedded\/:id, which renders\nexternalUrl inside an iframe shell.\n',
|
||||
),
|
||||
color: zod.string(),
|
||||
isActive: zod.boolean(),
|
||||
isSystem: zod.boolean(),
|
||||
@@ -668,7 +707,12 @@ export const UpdateAppBody = zod.object({
|
||||
descriptionAr: zod.string().nullish(),
|
||||
descriptionEn: zod.string().nullish(),
|
||||
iconName: zod.string().optional(),
|
||||
imageUrl: zod.string().nullish(),
|
||||
route: zod.string().optional(),
|
||||
externalUrl: zod.string().nullish(),
|
||||
openMode: zod
|
||||
.enum(["internal", "external_tab", "external_iframe"])
|
||||
.optional(),
|
||||
color: zod.string().optional(),
|
||||
isActive: zod.boolean().optional(),
|
||||
sortOrder: zod.number().optional(),
|
||||
@@ -682,7 +726,24 @@ export const UpdateAppResponse = zod.object({
|
||||
descriptionAr: zod.string().nullish(),
|
||||
descriptionEn: zod.string().nullish(),
|
||||
iconName: zod.string(),
|
||||
imageUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Optional uploaded image used in place of the Lucide icon on the\nlauncher tile. Stored as the object-storage path returned by the\nupload presigner (e.g. \"\/objects\/<id>\").\n',
|
||||
),
|
||||
route: zod.string(),
|
||||
externalUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'For openMode \"external_tab\" or \"external_iframe\": the URL the\nlauncher should open. Ignored when openMode is \"internal\".\n',
|
||||
),
|
||||
openMode: zod
|
||||
.enum(["internal", "external_tab", "external_iframe"])
|
||||
.describe(
|
||||
'How the launcher opens this app: \"internal\" navigates to `route`\ninside the SPA; \"external_tab\" opens externalUrl in a new tab;\n\"external_iframe\" navigates to \/embedded\/:id, which renders\nexternalUrl inside an iframe shell.\n',
|
||||
),
|
||||
color: zod.string(),
|
||||
isActive: zod.boolean(),
|
||||
isSystem: zod.boolean(),
|
||||
@@ -852,7 +913,24 @@ export const UpdateMyAppOrderResponseItem = zod.object({
|
||||
descriptionAr: zod.string().nullish(),
|
||||
descriptionEn: zod.string().nullish(),
|
||||
iconName: zod.string(),
|
||||
imageUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Optional uploaded image used in place of the Lucide icon on the\nlauncher tile. Stored as the object-storage path returned by the\nupload presigner (e.g. \"\/objects\/<id>\").\n',
|
||||
),
|
||||
route: zod.string(),
|
||||
externalUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'For openMode \"external_tab\" or \"external_iframe\": the URL the\nlauncher should open. Ignored when openMode is \"internal\".\n',
|
||||
),
|
||||
openMode: zod
|
||||
.enum(["internal", "external_tab", "external_iframe"])
|
||||
.describe(
|
||||
'How the launcher opens this app: \"internal\" navigates to `route`\ninside the SPA; \"external_tab\" opens externalUrl in a new tab;\n\"external_iframe\" navigates to \/embedded\/:id, which renders\nexternalUrl inside an iframe shell.\n',
|
||||
),
|
||||
color: zod.string(),
|
||||
isActive: zod.boolean(),
|
||||
isSystem: zod.boolean(),
|
||||
|
||||
Reference in New Issue
Block a user