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 when an external mode is chosen. Internal route field is hidden entirely when an external mode is selected, and locked (readOnly + lock hint) when editing a built-in app whose path is hardcoded in the SPA. 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. Exposed via subpath export `@workspace/db/built-in-apps`; the file has zero imports so the browser bundle can use it without pulling in `pg`. tx-os now imports it directly — duplicate FE constant removed. - Built-in slug list: services, notifications, admin, notes, my-orders, orders-incoming, executive-meetings (everything with a hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar / documents are seeded but admin-defined and remain editable. - 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.*. - scripts/src/seed.ts: drift guard throws if a seeded built-in slug uses a route that does not match the hardcoded SPA route. Tests: - New API test apps-builtin-route-lock.test.mjs (4/4 pass): reject built-in route change, allow non-route built-in updates, allow non-builtin route changes, allow built-in same-route no-op. - New Playwright E2E admin-app-image-external-embedded.spec.mjs (passes): launcher renders custom image_url, external_tab opens external URL via window.open, external_iframe navigates to /embedded/:id and renders <iframe src=externalUrl>. Out of scope (pre-existing, not introduced here): - 3 failing tests in executive-meetings-* and tsc errors in api-server/src/routes/executive-meetings.ts.
This commit is contained in:
@@ -52,6 +52,7 @@
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@workspace/api-client-react": "workspace:*",
|
||||
"@workspace/db": "workspace:*",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"cmdk": "^1.1.1",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -129,6 +129,7 @@ import { ListUsersStatus } from "@workspace/api-client-react";
|
||||
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { isBuiltinAppSlug } from "@workspace/db/built-in-apps";
|
||||
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, Lock, ExternalLink, type LucideIcon } from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
@@ -1238,25 +1239,8 @@ 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,
|
||||
);
|
||||
editingApp.id !== undefined && isBuiltinAppSlug(editingApp.form.slug);
|
||||
const isExternal = editingApp.form.openMode !== "internal";
|
||||
return (
|
||||
<div
|
||||
@@ -1347,34 +1331,33 @@ export default function AdminPage() {
|
||||
{lang === "ar" ? "معرّف فريد للتطبيق (أحرف صغيرة بدون مسافات)" : "Unique app identifier (lowercase, no spaces)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<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 } })}
|
||||
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">
|
||||
{isBuiltin
|
||||
? t("admin.builtinPathLocked")
|
||||
: isExternal
|
||||
? (lang === "ar" ? "غير مستخدم لتطبيقات الروابط الخارجية" : "Not used for external-link apps")
|
||||
{!isExternal && (
|
||||
<div className="space-y-1.5" data-testid="app-route-field">
|
||||
<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 } })}
|
||||
readOnly={isBuiltin}
|
||||
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 && "opacity-70 cursor-not-allowed",
|
||||
)}
|
||||
dir="ltr"
|
||||
placeholder="/notes"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 leading-snug">
|
||||
{isBuiltin
|
||||
? t("admin.builtinPathLocked")
|
||||
: (lang === "ar" ? "المسار داخل التطبيق (مثل /notes)" : "URL path inside the app (e.g. /notes)")}
|
||||
</p>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* App image (optional, replaces the Lucide icon on the launcher) */}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Task #517 E2E:
|
||||
// 1) An app with an `image_url` renders that image (not a Lucide icon)
|
||||
// on the launcher tile.
|
||||
// 2) An app with `open_mode='external_tab'` opens its `external_url` in
|
||||
// a new browser tab when the launcher tile is clicked (we intercept
|
||||
// window.open).
|
||||
// 3) An app with `open_mode='external_iframe'` navigates the SPA to
|
||||
// `/embedded/:id`, which renders an <iframe src=externalUrl>.
|
||||
// All three apps are inserted directly into the DB (via pg) and cleaned
|
||||
// up in afterAll, so this test is self-contained.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run this test");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
// External https URL — resolveServiceImageUrl passes http(s) through
|
||||
// untouched, so the <img src=...> on the tile equals this exact value.
|
||||
// We don't actually need the network fetch to succeed; we only assert
|
||||
// that an <img> with this src is rendered (i.e. a custom image is
|
||||
// being used instead of a Lucide icon).
|
||||
const CUSTOM_IMAGE_URL = "https://example.invalid/test-tile-image.png";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdAppIds = [];
|
||||
const createdUserIds = [];
|
||||
let username;
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
async function insertApp({ slug, nameEn, openMode, imageUrl, externalUrl, route }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO apps
|
||||
(slug, name_ar, name_en, icon_name, route, color, sort_order,
|
||||
is_active, image_url, external_url, open_mode)
|
||||
VALUES ($1, $2, $3, 'Grid2X2', $4, '#6366f1', 9999,
|
||||
TRUE, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
[slug, nameEn, nameEn, route, imageUrl, externalUrl, openMode],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdAppIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdAppIds.length > 0) {
|
||||
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [createdAppIds]);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds]);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [createdUserIds]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test.beforeAll(async () => {
|
||||
username = `e2e_app_open_${uniqueSuffix()}`;
|
||||
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`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
createdUserIds.push(rows[0].id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`,
|
||||
[rows[0].id],
|
||||
);
|
||||
});
|
||||
|
||||
async function loginUI(page) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(TEST_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000 }),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
test("launcher renders custom image_url and external open modes work", async ({ page, context }) => {
|
||||
// Insert three apps with distinct, identifiable names so we can find
|
||||
// their tiles on the launcher.
|
||||
const stamp = uniqueSuffix();
|
||||
const imageAppName = `ImageApp_${stamp}`;
|
||||
const tabAppName = `TabApp_${stamp}`;
|
||||
const iframeAppName = `IframeApp_${stamp}`;
|
||||
|
||||
await insertApp({
|
||||
slug: `image_app_${stamp}`,
|
||||
nameEn: imageAppName,
|
||||
openMode: "internal",
|
||||
imageUrl: CUSTOM_IMAGE_URL,
|
||||
externalUrl: null,
|
||||
route: `/img-${stamp}`,
|
||||
});
|
||||
await insertApp({
|
||||
slug: `tab_app_${stamp}`,
|
||||
nameEn: tabAppName,
|
||||
openMode: "external_tab",
|
||||
imageUrl: null,
|
||||
externalUrl: "https://example.com/tab-target",
|
||||
route: "/unused-tab",
|
||||
});
|
||||
const iframeAppId = await insertApp({
|
||||
slug: `iframe_app_${stamp}`,
|
||||
nameEn: iframeAppName,
|
||||
openMode: "external_iframe",
|
||||
imageUrl: null,
|
||||
externalUrl: "https://example.com/iframe-target",
|
||||
route: "/unused-iframe",
|
||||
});
|
||||
|
||||
await loginUI(page);
|
||||
|
||||
// 1) Custom image rendering on launcher tile.
|
||||
const imageTile = page.getByText(imageAppName, { exact: true }).first();
|
||||
await expect(imageTile).toBeVisible({ timeout: 15_000 });
|
||||
// Tile should contain an <img> whose src is our custom image URL —
|
||||
// proves the launcher uses the uploaded image instead of a Lucide icon.
|
||||
const tileImg = page.locator(`img[src="${CUSTOM_IMAGE_URL}"]`).first();
|
||||
await expect(tileImg).toHaveCount(1);
|
||||
|
||||
// 2) external_tab opens external URL in a new tab.
|
||||
// window.open is what the launcher uses for external_tab. Stub it
|
||||
// to record the URL instead of actually navigating.
|
||||
await page.evaluate(() => {
|
||||
window.__opened = [];
|
||||
window.open = (url) => {
|
||||
window.__opened.push(String(url));
|
||||
return null;
|
||||
};
|
||||
});
|
||||
await page.getByText(tabAppName, { exact: true }).first().click();
|
||||
const openedUrls = await page.evaluate(() => window.__opened ?? []);
|
||||
expect(openedUrls).toContain("https://example.com/tab-target");
|
||||
|
||||
// 3) external_iframe → SPA navigates to /embedded/:id and renders iframe.
|
||||
await page.getByText(iframeAppName, { exact: true }).first().click();
|
||||
await page.waitForURL(new RegExp(`/embedded/${iframeAppId}(?:$|[?/])`));
|
||||
const iframeEl = page.locator(`iframe[src="https://example.com/iframe-target"]`);
|
||||
await expect(iframeEl).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./schema": "./src/schema/index.ts"
|
||||
"./schema": "./src/schema/index.ts",
|
||||
"./built-in-apps": "./src/built-in-apps.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"pre-push-cleanup": "tsx ./scripts/pre-push-cleanup.ts",
|
||||
|
||||
@@ -5,12 +5,18 @@
|
||||
// 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.
|
||||
//
|
||||
// IMPORTANT: This file has zero imports on purpose so it can be
|
||||
// consumed safely from both the API server (Node, via @workspace/db)
|
||||
// and the tx-os browser bundle (via @workspace/db/built-in-apps,
|
||||
// which avoids pulling in `pg`).
|
||||
export const BUILTIN_APP_SLUGS = [
|
||||
"services",
|
||||
"notifications",
|
||||
"admin",
|
||||
"notes",
|
||||
"my-orders",
|
||||
"orders-incoming",
|
||||
"executive-meetings",
|
||||
] as const;
|
||||
|
||||
|
||||
Generated
+3
@@ -557,6 +557,9 @@ importers:
|
||||
'@workspace/api-client-react':
|
||||
specifier: workspace:*
|
||||
version: link:../../lib/api-client-react
|
||||
'@workspace/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../lib/db
|
||||
class-variance-authority:
|
||||
specifier: 'catalog:'
|
||||
version: 0.7.1
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { db } from "@workspace/db";
|
||||
import { BUILTIN_APP_SLUGS } from "@workspace/db/built-in-apps";
|
||||
import {
|
||||
usersTable,
|
||||
rolesTable,
|
||||
@@ -225,6 +226,33 @@ async function main() {
|
||||
},
|
||||
];
|
||||
|
||||
// Drift guard: every built-in slug (those with hardcoded routes in the
|
||||
// SPA, see lib/db/src/built-in-apps.ts) that this seed actually creates
|
||||
// here must use the same `route` the SPA expects. We don't require ALL
|
||||
// built-in slugs to be seeded (some, like "notes" / "my-orders" /
|
||||
// "orders-incoming", are user-created later), but if we DO seed one,
|
||||
// the route must match the hardcoded SPA route.
|
||||
const expectedBuiltinRoutes: Record<string, string> = {
|
||||
services: "/services",
|
||||
notifications: "/notifications",
|
||||
admin: "/admin",
|
||||
notes: "/notes",
|
||||
"my-orders": "/my-orders",
|
||||
"orders-incoming": "/orders/incoming",
|
||||
"executive-meetings": "/executive-meetings",
|
||||
};
|
||||
for (const a of apps) {
|
||||
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {
|
||||
const expected = expectedBuiltinRoutes[a.slug];
|
||||
if (expected && a.route !== expected) {
|
||||
throw new Error(
|
||||
`seed: built-in app "${a.slug}" must use route "${expected}" (got "${a.route}"). ` +
|
||||
`Built-in slugs are listed in lib/db/src/built-in-apps.ts and have hardcoded SPA routes in artifacts/tx-os/src/App.tsx.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const insertedApps = await db.insert(appsTable).values(apps).onConflictDoNothing().returning();
|
||||
console.log("Apps created");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user