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:
riyadhafraa
2026-05-12 12:23:38 +00:00
parent d8baac9317
commit a794f92e61
14 changed files with 758 additions and 12 deletions
@@ -415,6 +415,21 @@ export interface UpdateGroupBody {
userIds?: number[];
}
/**
* How the launcher opens this app: "internal" navigates to `route`
inside the SPA; "external_tab" opens externalUrl in a new tab;
"external_iframe" navigates to /embedded/:id, which renders
externalUrl inside an iframe shell.
*/
export type AppOpenMode = (typeof AppOpenMode)[keyof typeof AppOpenMode];
export const AppOpenMode = {
internal: "internal",
external_tab: "external_tab",
external_iframe: "external_iframe",
} as const;
export interface App {
id: number;
slug: string;
@@ -425,7 +440,28 @@ export interface App {
/** @nullable */
descriptionEn?: string | null;
iconName: string;
/**
* Optional uploaded image used in place of the Lucide icon on the
launcher tile. Stored as the object-storage path returned by the
upload presigner (e.g. "/objects/<id>").
* @nullable
*/
imageUrl?: string | null;
route: string;
/**
* For openMode "external_tab" or "external_iframe": the URL the
launcher should open. Ignored when openMode is "internal".
* @nullable
*/
externalUrl?: string | null;
/** How the launcher opens this app: "internal" navigates to `route`
inside the SPA; "external_tab" opens externalUrl in a new tab;
"external_iframe" navigates to /embedded/:id, which renders
externalUrl inside an iframe shell.
*/
openMode: AppOpenMode;
color: string;
isActive: boolean;
isSystem: boolean;
@@ -448,6 +484,15 @@ the admin list endpoint (GET /admin/apps).
openCount?: number;
}
export type CreateAppBodyOpenMode =
(typeof CreateAppBodyOpenMode)[keyof typeof CreateAppBodyOpenMode];
export const CreateAppBodyOpenMode = {
internal: "internal",
external_tab: "external_tab",
external_iframe: "external_iframe",
} as const;
export interface CreateAppBody {
slug: string;
nameAr: string;
@@ -457,7 +502,12 @@ export interface CreateAppBody {
/** @nullable */
descriptionEn?: string | null;
iconName: string;
/** @nullable */
imageUrl?: string | null;
route: string;
/** @nullable */
externalUrl?: string | null;
openMode?: CreateAppBodyOpenMode;
color: string;
isActive?: boolean;
isSystem?: boolean;
@@ -466,6 +516,15 @@ export interface CreateAppBody {
permissionIds?: number[];
}
export type UpdateAppBodyOpenMode =
(typeof UpdateAppBodyOpenMode)[keyof typeof UpdateAppBodyOpenMode];
export const UpdateAppBodyOpenMode = {
internal: "internal",
external_tab: "external_tab",
external_iframe: "external_iframe",
} as const;
export interface UpdateAppBody {
nameAr?: string;
nameEn?: string;
@@ -474,7 +533,12 @@ export interface UpdateAppBody {
/** @nullable */
descriptionEn?: string | null;
iconName?: string;
/** @nullable */
imageUrl?: string | null;
route?: string;
/** @nullable */
externalUrl?: string | null;
openMode?: UpdateAppBodyOpenMode;
color?: string;
isActive?: boolean;
sortOrder?: number;