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:
@@ -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}`);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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": "ابحث في الملاحظات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user