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
+20 -1
View File
@@ -1,6 +1,6 @@
import { Router, type IRouter } from "express";
import { eq, and, asc, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import { db, isBuiltinAppSlug } from "@workspace/db";
import {
appsTable,
appPermissionsTable,
@@ -381,6 +381,25 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
.from(appsTable)
.where(eq(appsTable.id, params.data.id));
// Task #517: lock the route field for built-in apps. Their paths are
// hardcoded in artifacts/tx-os/src/App.tsx — letting an admin retype
// the path silently breaks the launcher entry (the user reported this
// after editing the Executive Meetings app). We allow admins to keep
// editing every other field (icon, image, color, name, etc.) but a
// route change targeting a built-in slug is rejected up-front.
if (
previous &&
isBuiltinAppSlug(previous.slug) &&
parsed.data.route !== undefined &&
parsed.data.route !== previous.route
) {
res.status(400).json({
error: "The path of a built-in app cannot be changed.",
code: "builtin_route_locked",
});
return;
}
const [app] = await db
.update(appsTable)
.set(parsed.data)
@@ -0,0 +1,199 @@
// Task #517: PATCH /apps/:id must reject route changes whose target row's
// slug is in BUILTIN_APP_SLUGS — those routes are hardcoded in the SPA
// (artifacts/tx-os/src/App.tsx) and silently break the launcher when an
// admin edits them. Other fields (icon, image, color, name, openMode,
// externalUrl, sortOrder) must still be editable, including for built-in
// apps. Apps whose slug is not built-in keep accepting route changes.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
let adminId;
let adminUsername;
let adminCookie;
const createdAppIds = [];
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
return setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
}
async function createAppRow({ slug, route }) {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const realSlug = slug ?? `customapp_${stamp}`;
const realRoute = route ?? `/custom-${stamp}`;
const { rows } = await pool.query(
`INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order)
VALUES ($1, $2, $3, $4, TRUE, 0) RETURNING id`,
[realSlug, realSlug, realSlug, realRoute],
);
createdAppIds.push(rows[0].id);
return { id: rows[0].id, slug: realSlug, route: realRoute };
}
before(async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
adminUsername = `admin_lock_${stamp}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $1, 'en', TRUE) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
});
after(async () => {
if (createdAppIds.length > 0) {
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [createdAppIds]);
}
if (adminId) {
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]);
await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]);
}
await pool.end();
});
test("PATCH /apps/:id rejects route changes for built-in slugs with code=builtin_route_locked", async () => {
// Pick a built-in slug. We INSERT a row with that exact slug — the live
// DB seeds them too, but using a freshly inserted row keeps the test
// self-contained and avoids touching the seeded /admin/etc apps.
// First clean any stray prior test row for this slug; the schema has a
// UNIQUE constraint on slug.
const slug = "executive-meetings";
const original = await pool.query(`SELECT id, route FROM apps WHERE slug = $1`, [slug]);
let appId;
let originalRoute;
if (original.rowCount > 0) {
appId = original.rows[0].id;
originalRoute = original.rows[0].route;
} else {
const created = await createAppRow({ slug, route: "/executive-meetings" });
appId = created.id;
originalRoute = created.route;
}
const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Cookie: adminCookie,
},
body: JSON.stringify({ route: "/something-else" }),
});
assert.equal(res.status, 400, `expected 400, got ${res.status}`);
const body = await res.json();
assert.equal(body.code, "builtin_route_locked");
// Confirm the row was NOT mutated.
const after = await pool.query(`SELECT route FROM apps WHERE id = $1`, [appId]);
assert.equal(after.rows[0].route, originalRoute);
});
test("PATCH /apps/:id allows non-route updates on built-in apps", async () => {
const slug = "notes";
const original = await pool.query(`SELECT id, route, icon_name FROM apps WHERE slug = $1`, [slug]);
let appId;
let originalRoute;
let originalIcon;
if (original.rowCount > 0) {
appId = original.rows[0].id;
originalRoute = original.rows[0].route;
originalIcon = original.rows[0].icon_name;
} else {
const created = await createAppRow({ slug, route: "/notes" });
appId = created.id;
originalRoute = created.route;
originalIcon = "Grid";
}
// Change icon + openMode but keep route untouched (omit it).
const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Cookie: adminCookie,
},
body: JSON.stringify({ iconName: "Sparkles", openMode: "internal" }),
});
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
const after = await pool.query(
`SELECT route, icon_name, open_mode FROM apps WHERE id = $1`,
[appId],
);
assert.equal(after.rows[0].route, originalRoute, "route must not change");
assert.equal(after.rows[0].icon_name, "Sparkles");
assert.equal(after.rows[0].open_mode, "internal");
// Restore icon so the test is idempotent for re-runs.
await pool.query(`UPDATE apps SET icon_name = $1 WHERE id = $2`, [originalIcon, appId]);
});
test("PATCH /apps/:id allows route changes on non-built-in apps", async () => {
const created = await createAppRow({});
const newRoute = `${created.route}_v2`;
const res = await fetch(`${API_BASE}/api/apps/${created.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Cookie: adminCookie,
},
body: JSON.stringify({ route: newRoute }),
});
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
const after = await pool.query(`SELECT route FROM apps WHERE id = $1`, [created.id]);
assert.equal(after.rows[0].route, newRoute);
});
test("PATCH /apps/:id with same route on built-in slug is a no-op success", async () => {
// Sending route equal to current value should NOT be rejected, since
// it isn't actually changing the path.
const slug = "executive-meetings";
const original = await pool.query(`SELECT id, route FROM apps WHERE slug = $1`, [slug]);
let appId;
let route;
if (original.rowCount > 0) {
appId = original.rows[0].id;
route = original.rows[0].route;
} else {
const created = await createAppRow({ slug, route: "/executive-meetings" });
appId = created.id;
route = created.route;
}
const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Cookie: adminCookie,
},
body: JSON.stringify({ route }),
});
assert.equal(res.status, 200, `expected 200, got ${res.status}`);
});
+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);
};
@@ -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;
+34
View File
@@ -3626,8 +3626,27 @@ components:
type: ["string", "null"]
iconName:
type: string
imageUrl:
type: ["string", "null"]
description: |
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>").
route:
type: string
externalUrl:
type: ["string", "null"]
description: |
For openMode "external_tab" or "external_iframe": the URL the
launcher should open. Ignored when openMode is "internal".
openMode:
type: string
enum: [internal, external_tab, external_iframe]
description: |
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.
color:
type: string
isActive:
@@ -3666,6 +3685,7 @@ components:
- nameEn
- iconName
- route
- openMode
- color
- isActive
- isSystem
@@ -3688,8 +3708,15 @@ components:
type: ["string", "null"]
iconName:
type: string
imageUrl:
type: ["string", "null"]
route:
type: string
externalUrl:
type: ["string", "null"]
openMode:
type: string
enum: [internal, external_tab, external_iframe]
color:
type: string
isActive:
@@ -3724,8 +3751,15 @@ components:
type: ["string", "null"]
iconName:
type: string
imageUrl:
type: ["string", "null"]
route:
type: string
externalUrl:
type: ["string", "null"]
openMode:
type: string
enum: [internal, external_tab, external_iframe]
color:
type: string
isActive:
+78
View File
@@ -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(),
+21
View File
@@ -0,0 +1,21 @@
// Slugs of apps whose `route` is hardcoded in the tx-os SPA router
// (see artifacts/tx-os/src/App.tsx). Editing the route of one of these
// apps in the admin panel breaks navigation silently — the launcher
// would set the new path but no <Route> matches it. Task #517: server
// rejects route changes for these slugs and the admin form locks the
// route field with a helpful notice. Add a slug here whenever you wire
// a new hardcoded <Route> in App.tsx for a seeded app.
export const BUILTIN_APP_SLUGS = [
"services",
"notifications",
"admin",
"notes",
"my-orders",
"executive-meetings",
] as const;
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
export function isBuiltinAppSlug(slug: string): slug is BuiltinAppSlug {
return (BUILTIN_APP_SLUGS as readonly string[]).includes(slug);
}
+1
View File
@@ -14,3 +14,4 @@ export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
export * from "./schema";
export * from "./built-in-apps";
+13
View File
@@ -10,7 +10,20 @@ export const appsTable = pgTable("apps", {
descriptionAr: text("description_ar"),
descriptionEn: text("description_en"),
iconName: varchar("icon_name", { length: 100 }).notNull().default("Grid"),
// Optional uploaded image used in place of the Lucide icon on the
// launcher tile. Stored as the object-storage path (e.g. "/objects/<id>")
// returned by the upload presigner, so resolveServiceImageUrl knows how
// to translate it for the browser.
imageUrl: varchar("image_url", { length: 500 }),
route: varchar("route", { length: 500 }).notNull(),
// For openMode "external_tab"|"external_iframe": the target URL.
// Ignored when openMode is "internal".
externalUrl: varchar("external_url", { length: 1000 }),
// How the launcher should open this app:
// internal — navigate to `route` inside the SPA (default)
// external_tab — window.open(externalUrl, "_blank")
// external_iframe— navigate to /embedded/:id, which renders externalUrl in an iframe shell
openMode: varchar("open_mode", { length: 20 }).notNull().default("internal"),
color: varchar("color", { length: 50 }).notNull().default("#6366f1"),
isActive: boolean("is_active").notNull().default(true),
isSystem: boolean("is_system").notNull().default(false),