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
+2
View File
@@ -22,6 +22,7 @@ import NotesPage from "@/pages/notes";
import MyOrdersPage from "@/pages/my-orders";
import OrdersIncomingPage from "@/pages/orders-incoming";
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
import EmbeddedAppPage from "@/pages/embedded-app";
const queryClient = new QueryClient({
defaultOptions: {
@@ -58,6 +59,7 @@ function Router() {
<Route path="/my-orders" component={MyOrdersPage} />
<Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/executive-meetings" component={ExecutiveMeetingsPage} />
<Route path="/embedded/:id" component={EmbeddedAppPage} />
<Route path="/" component={HomePage} />
<Route component={NotFound} />
</Switch>
+17
View File
@@ -298,6 +298,15 @@
"appRoute": "المسار",
"appIcon": "الأيقونة",
"appColor": "اللون",
"appImage": "صورة",
"appExternalUrl": "رابط خارجي",
"appOpenMode": {
"label": "طريقة الفتح",
"internal": "صفحة داخلية (افتراضي)",
"external_tab": "فتح الرابط في تبويب جديد",
"external_iframe": "فتح الرابط داخل التطبيق"
},
"builtinPathLocked": "لا يمكن تغيير مسار التطبيقات المدمجة.",
"appSortOrder": "الترتيب",
"appPermissions": {
"title": "الصلاحيات المطلوبة",
@@ -904,6 +913,14 @@
"next": "التالي",
"empty": "(فارغ)"
},
"embeddedFrame": {
"title": "تطبيق مدمج",
"back": "العودة للرئيسية",
"openInNewTab": "فتح في تبويب جديد",
"loadError": "تعذّر تحميل التطبيق.",
"notFound": "التطبيق غير موجود.",
"notEmbeddable": "هذا التطبيق غير مهيّأ للعرض المدمج."
},
"notes": {
"title": "الملاحظات",
"searchPlaceholder": "ابحث في الملاحظات",
+17
View File
@@ -298,6 +298,15 @@
"appRoute": "Route",
"appIcon": "Icon",
"appColor": "Color",
"appImage": "Image",
"appExternalUrl": "External URL",
"appOpenMode": {
"label": "How to open",
"internal": "Built-in page (default)",
"external_tab": "Open external link in a new tab",
"external_iframe": "Open external link inside the app"
},
"builtinPathLocked": "The path of a built-in app cannot be changed.",
"appSortOrder": "Sort Order",
"appPermissions": {
"title": "Required permissions",
@@ -816,6 +825,14 @@
"next": "Next",
"empty": "(empty)"
},
"embeddedFrame": {
"title": "Embedded app",
"back": "Back to home",
"openInNewTab": "Open in a new tab",
"loadError": "Could not load the app.",
"notFound": "App not found.",
"notEmbeddable": "This app is not configured to be embedded."
},
"notes": {
"title": "Notes",
"searchPlaceholder": "Search notes",
+160 -9
View File
@@ -130,7 +130,7 @@ import { useQueryClient, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, type LucideIcon } from "lucide-react";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, type LucideIcon } from "lucide-react";
import * as LucideIcons from "lucide-react";
function resolveAppIcon(name: string): LucideIcon | null {
@@ -215,12 +215,20 @@ function LeaderboardAvatar({
);
}
type AppOpenMode = "internal" | "external_tab" | "external_iframe";
type AppForm = {
nameAr: string;
nameEn: string;
slug: string;
iconName: string;
// Optional uploaded image — when set, the launcher renders this instead
// of the Lucide icon. Stored as the object-storage path returned by the
// upload presigner (e.g. "/objects/<id>"). Empty string = none.
imageUrl: string;
route: string;
externalUrl: string;
openMode: AppOpenMode;
color: string;
sortOrder: number;
// Only used when creating a new app — admins can pre-set the required
@@ -887,7 +895,10 @@ export default function AdminPage() {
nameEn: app.nameEn ?? "",
slug: app.slug ?? "",
iconName: app.iconName ?? "Grid2X2",
imageUrl: app.imageUrl ?? "",
route: app.route ?? "/",
externalUrl: app.externalUrl ?? "",
openMode: ((app.openMode as AppOpenMode) ?? "internal"),
color: app.color ?? "#6366f1",
sortOrder: app.sortOrder ?? 0,
permissionIds: [],
@@ -1032,7 +1043,7 @@ export default function AdminPage() {
);
};
const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", route: "/", color: "#6366f1", sortOrder: 0, permissionIds: [] };
const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", imageUrl: "", route: "/", externalUrl: "", openMode: "internal", color: "#6366f1", sortOrder: 0, permissionIds: [] };
const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", imageUrl: "", isAvailable: true };
// #196: Switch to the audit-log section pre-filtered by the given
@@ -1061,32 +1072,67 @@ export default function AdminPage() {
const handleSaveApp = () => {
if (!editingApp) return;
// Normalize the optional URL/image fields. The OpenAPI spec types
// them as ["string","null"], so an empty string from the form would
// be rejected by zod. We send null whenever the admin left them
// blank (which is the meaningful value for "no image"/"no URL").
const normalize = (form: AppForm) => ({
...form,
imageUrl: form.imageUrl?.trim() ? form.imageUrl.trim() : null,
externalUrl: form.externalUrl?.trim() ? form.externalUrl.trim() : null,
});
// Surface server-side errors that the user can act on. The
// built-in route lock returns { code: "builtin_route_locked" }.
const onError = async (err: unknown) => {
let message: string | undefined;
if (err && typeof err === "object" && "response" in err) {
const r = (err as { response?: Response }).response;
if (r) {
try {
const body = await r.clone().json();
if (body?.code === "builtin_route_locked") {
message = t("admin.builtinPathLocked");
} else if (typeof body?.error === "string") {
message = body.error;
}
} catch {
/* fall through */
}
}
}
toast({
title: t("admin.uploadFailed"),
description: message,
variant: "destructive",
});
};
if (editingApp.id) {
// Edit path: permissionIds aren't part of the update payload — the
// existing AppPermissionsEditor manages them with its own impact
// preview + audit, so strip the field before sending.
const { permissionIds: _ignored, ...updateData } = editingApp.form;
const { permissionIds: _ignored, ...rest } = normalize(editingApp.form);
void _ignored;
updateApp.mutate(
{ id: editingApp.id, data: updateData },
{ id: editingApp.id, data: rest },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
setEditingApp(null);
toast({ title: t("common.success") });
},
onError,
},
);
} else {
const { permissionIds, ...rest } = editingApp.form;
const { permissionIds, ...rest } = normalize(editingApp.form);
createApp.mutate(
{
// Only include permissionIds if the admin actually picked some
// so the request body stays minimal and the server's "no
// restriction" path is unchanged.
data:
permissionIds.length > 0
? { ...rest, permissionIds }
(editingApp.form.permissionIds.length > 0)
? { ...rest, permissionIds: editingApp.form.permissionIds }
: rest,
},
{
@@ -1095,8 +1141,10 @@ export default function AdminPage() {
setEditingApp(null);
toast({ title: t("common.success") });
},
onError,
},
);
void permissionIds;
}
};
@@ -1190,6 +1238,26 @@ export default function AdminPage() {
const previewColor = /^#[0-9a-fA-F]{6}$/.test(editingApp.form.color)
? editingApp.form.color
: "#6366f1";
// Mirror of BUILTIN_APP_SLUGS in @workspace/db (see
// lib/db/src/built-in-apps.ts). Their `route` is hardcoded in
// App.tsx, so the API rejects route changes for these slugs.
// Duplicated here because @workspace/db pulls in `pg` which
// can't run in the browser; kept tiny and pinned next to the
// server constant in code review.
const BUILTIN_APP_SLUGS_FE = [
"services",
"notifications",
"admin",
"notes",
"my-orders",
"executive-meetings",
] as const;
const isBuiltin =
editingApp.id !== undefined &&
(BUILTIN_APP_SLUGS_FE as readonly string[]).includes(
editingApp.form.slug,
);
const isExternal = editingApp.form.openMode !== "internal";
return (
<div
className="fixed inset-0 bg-slate-950/50 backdrop-blur-md z-50 flex items-center justify-center p-4 animate-in fade-in duration-150"
@@ -1283,20 +1351,100 @@ export default function AdminPage() {
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<Link2 size={12} className="text-slate-500" />
{t("admin.appRoute")}
{isBuiltin && (
<Lock size={10} className="text-slate-400" aria-hidden="true" />
)}
</Label>
<Input
value={editingApp.form.route}
onChange={(e) => setEditingApp({ ...editingApp, form: { ...editingApp.form, route: e.target.value } })}
className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm font-mono focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400"
readOnly={isBuiltin || isExternal}
disabled={isExternal}
className={cn(
"bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm font-mono focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400",
(isBuiltin || isExternal) && "opacity-70 cursor-not-allowed",
)}
dir="ltr"
placeholder="/notes"
/>
<p className="text-[11px] text-slate-400 leading-snug">
{lang === "ar" ? "المسار داخل التطبيق (مثل /notes)" : "URL path inside the app (e.g. /notes)"}
{isBuiltin
? t("admin.builtinPathLocked")
: isExternal
? (lang === "ar" ? "غير مستخدم لتطبيقات الروابط الخارجية" : "Not used for external-link apps")
: (lang === "ar" ? "المسار داخل التطبيق (مثل /notes)" : "URL path inside the app (e.g. /notes)")}
</p>
</div>
</div>
{/* App image (optional, replaces the Lucide icon on the launcher) */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<Upload size={12} className="text-slate-500" />
{t("admin.appImage")}
</Label>
<ServiceImageUploader
value={editingApp.form.imageUrl}
onChange={(url) =>
setEditingApp({
...editingApp,
form: { ...editingApp.form, imageUrl: url },
})
}
/>
<p className="text-[11px] text-slate-400 leading-snug">
{lang === "ar"
? "صورة اختيارية تظهر مكان الأيقونة في الشاشة الرئيسية"
: "Optional image shown on the launcher tile in place of the icon"}
</p>
</div>
{/* Open mode + external URL */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<ExternalLink size={12} className="text-slate-500" />
{t("admin.appOpenMode.label")}
</Label>
<select
value={editingApp.form.openMode}
onChange={(e) =>
setEditingApp({
...editingApp,
form: {
...editingApp.form,
openMode: e.target.value as AppOpenMode,
},
})
}
className="bg-slate-50/70 border border-slate-200 rounded-lg h-10 text-sm w-full px-3 focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400"
dir={lang === "ar" ? "rtl" : "ltr"}
>
<option value="internal">{t("admin.appOpenMode.internal")}</option>
<option value="external_tab">{t("admin.appOpenMode.external_tab")}</option>
<option value="external_iframe">{t("admin.appOpenMode.external_iframe")}</option>
</select>
{isExternal && (
<div className="space-y-1.5 pt-2">
<Label className="text-xs font-medium text-slate-700">
{t("admin.appExternalUrl")}
</Label>
<Input
value={editingApp.form.externalUrl}
onChange={(e) =>
setEditingApp({
...editingApp,
form: { ...editingApp.form, externalUrl: e.target.value },
})
}
className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400"
dir="ltr"
placeholder="https://example.com"
type="url"
/>
</div>
)}
</div>
{/* Visuals row — icon + color with live previews */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Icon picker */}
@@ -1752,7 +1900,10 @@ export default function AdminPage() {
nameEn: app.nameEn ?? "",
slug: app.slug ?? "",
iconName: app.iconName ?? "Grid2X2",
imageUrl: app.imageUrl ?? "",
route: app.route ?? "/",
externalUrl: app.externalUrl ?? "",
openMode: ((app.openMode as AppOpenMode) ?? "internal"),
color: app.color ?? "#6366f1",
sortOrder: app.sortOrder ?? 0,
permissionIds: [],
+102
View File
@@ -0,0 +1,102 @@
import { useTranslation } from "react-i18next";
import { useLocation, useRoute } from "wouter";
import {
useGetApp,
getGetAppQueryKey,
} from "@workspace/api-client-react";
import { ArrowLeft, ArrowRight, ExternalLink } from "lucide-react";
export default function EmbeddedAppPage() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const isRtl = lang === "ar";
const [, setLocation] = useLocation();
const [, params] = useRoute<{ id: string }>("/embedded/:id");
const idStr = params?.id ?? "";
const id = Number(idStr);
const validId = Number.isFinite(id) && id > 0;
const { data: app, isLoading, error } = useGetApp(id, {
query: {
queryKey: getGetAppQueryKey(id),
enabled: validId,
},
});
const goBack = () => setLocation("/");
const Back = isRtl ? ArrowRight : ArrowLeft;
if (!validId) {
return (
<div className="min-h-screen flex items-center justify-center os-bg p-6 text-center">
<div>
<p className="text-slate-700 mb-3">{t("embeddedFrame.notFound")}</p>
<button
onClick={goBack}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm hover:bg-slate-50"
>
<Back size={16} /> {t("embeddedFrame.back")}
</button>
</div>
</div>
);
}
const name = app ? (lang === "ar" ? app.nameAr : app.nameEn) : "";
const url = app?.externalUrl ?? "";
// openMode must be external_iframe AND externalUrl must be set; otherwise
// there's nothing to render — fall back to a friendly notice.
const canEmbed = !!app && app.openMode === "external_iframe" && !!url;
return (
<div className="min-h-screen flex flex-col os-bg" dir={isRtl ? "rtl" : "ltr"}>
<div
className="border-b border-slate-200/70 px-3 py-2 flex items-center gap-2 sticky top-0 z-30 bg-white/95 backdrop-blur"
>
<button
onClick={goBack}
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("embeddedFrame.back")}
>
<Back size={18} />
</button>
<h1 className="text-sm font-semibold truncate flex-1">
{isLoading ? "…" : (name || t("embeddedFrame.title"))}
</h1>
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("embeddedFrame.openInNewTab")}
title={t("embeddedFrame.openInNewTab")}
>
<ExternalLink size={16} />
</a>
)}
</div>
<div className="flex-1 relative">
{error ? (
<div className="absolute inset-0 flex items-center justify-center text-sm text-slate-500">
{t("embeddedFrame.loadError")}
</div>
) : !canEmbed ? (
<div className="absolute inset-0 flex items-center justify-center text-sm text-slate-500 px-6 text-center">
{isLoading ? t("common.loading") : t("embeddedFrame.notEmbeddable")}
</div>
) : (
<iframe
src={url}
title={name || t("embeddedFrame.title")}
className="absolute inset-0 w-full h-full border-0"
// Permissive sandbox so the embedded site can run scripts and
// forms while still being isolated from our origin's cookies.
sandbox="allow-scripts allow-forms allow-popups allow-same-origin allow-downloads"
referrerPolicy="no-referrer-when-downgrade"
/>
)}
</div>
</div>
);
}
+30 -2
View File
@@ -55,6 +55,7 @@ import {
formatTime,
formatNumber,
} from "@/lib/i18n-format";
import { resolveServiceImageUrl } from "@/lib/image-url";
import {
Clock,
useNow,
@@ -100,7 +101,7 @@ function AppIconContent({
badgeCount = 0,
pulse = false,
}: {
app: { nameAr: string; nameEn: string; iconName: string; color: string };
app: { nameAr: string; nameEn: string; iconName: string; color: string; imageUrl?: string | null };
badgeCount?: number;
pulse?: boolean;
}) {
@@ -109,6 +110,10 @@ function AppIconContent({
const name = lang === "ar" ? app.nameAr : app.nameEn;
const Icon = resolveIcon(app.iconName);
// Task #517: when an admin uploads a custom image, render it in place
// of the Lucide icon. The image fills the tile (object-cover) so the
// gradient background still shows through transparent PNGs.
const resolvedImage = resolveServiceImageUrl(app.imageUrl ?? null);
return (
<>
@@ -120,7 +125,16 @@ function AppIconContent({
aria-hidden="true"
/>
)}
<Icon size={30} color="#FFFFFF" />
{resolvedImage ? (
<img
src={resolvedImage}
alt=""
className="absolute inset-0 w-full h-full object-cover rounded-[inherit]"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
) : (
<Icon size={30} color="#FFFFFF" />
)}
{badgeCount > 0 && (
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center font-bold tabular-nums shadow">
{badgeCount > 9 ? "9+" : badgeCount}
@@ -473,6 +487,20 @@ export default function HomePage() {
logAppOpen(app.id, { keepalive: true }).catch(() => {
// Best-effort tracking; ignore failures so navigation isn't blocked.
});
// Task #517: branch on the admin-configured open mode.
// external_tab — open the externalUrl in a new tab
// external_iframe — navigate to /embedded/:id which renders externalUrl
// inside an iframe shell with a back button
// internal (default) — navigate to the SPA route
const mode = app.openMode ?? "internal";
if (mode === "external_tab" && app.externalUrl) {
window.open(app.externalUrl, "_blank", "noopener,noreferrer");
return;
}
if (mode === "external_iframe" && app.externalUrl) {
setLocation(`/embedded/${app.id}`);
return;
}
setLocation(app.route);
};