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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user