diff --git a/clients/dashboard/src/api/tenants.ts b/clients/dashboard/src/api/tenants.ts new file mode 100644 index 0000000000..a4db52e93b --- /dev/null +++ b/clients/dashboard/src/api/tenants.ts @@ -0,0 +1,101 @@ +import { apiFetch } from "@/lib/api-client"; + +// ───────────────────────────────────────────────────────────────────────── +// Tenant theme / branding +// +// The theme endpoints are CURRENT-TENANT scoped server-side — they read the +// request's tenant (resolved from the caller's token) and act on that row. +// Unlike the admin app, the dashboard never targets a *different* tenant, so +// we send NO `tenant:` header: every call operates on the signed-in tenant. +// +// Types mirror clients/admin/src/api/tenants.ts (the two Vite apps duplicate +// by design — no cross-app imports). Keep the shapes in sync if the server +// DTO changes. Typography + layout exist on the server DTO but the editor +// omits them (parity with the admin card's v1 scope). +// ───────────────────────────────────────────────────────────────────────── + +export type PaletteDto = { + primary: string; + secondary: string; + tertiary: string; + background: string; + surface: string; + error: string; + warning: string; + success: string; + info: string; +}; + +export type BrandAssetsDto = { + logoUrl?: string | null; + logoDarkUrl?: string | null; + faviconUrl?: string | null; + deleteLogo?: boolean; + deleteLogoDark?: boolean; + deleteFavicon?: boolean; +}; + +export type TypographyDto = { + fontFamily: string; + headingFontFamily: string; + fontSizeBase: number; + lineHeightBase: number; +}; + +export type LayoutDto = { + borderRadius: string; + defaultElevation: number; +}; + +export type TenantThemeDto = { + lightPalette: PaletteDto; + darkPalette: PaletteDto; + brandAssets: BrandAssetsDto; + typography: TypographyDto; + layout: LayoutDto; + isDefault: boolean; +}; + +export const DEFAULT_LIGHT_PALETTE: PaletteDto = { + primary: "#2563EB", + secondary: "#0F172A", + tertiary: "#6366F1", + background: "#F8FAFC", + surface: "#FFFFFF", + error: "#DC2626", + warning: "#F59E0B", + success: "#16A34A", + info: "#0284C7", +}; + +export const DEFAULT_DARK_PALETTE: PaletteDto = { + primary: "#38BDF8", + secondary: "#94A3B8", + tertiary: "#818CF8", + background: "#0B1220", + surface: "#111827", + error: "#F87171", + warning: "#FBBF24", + success: "#22C55E", + info: "#38BDF8", +}; + +/** Fetch the current tenant's theme. Caller needs Tenants.ViewTheme (IsBasic). */ +export async function getTenantTheme(): Promise { + return apiFetch(`/api/v1/tenants/theme`); +} + +/** Save the current tenant's theme. Caller needs Tenants.UpdateTheme. */ +export async function updateTenantTheme(theme: TenantThemeDto): Promise { + await apiFetch(`/api/v1/tenants/theme`, { + method: "PUT", + body: JSON.stringify(theme), + }); +} + +/** Reset the current tenant's theme to framework defaults. Needs Tenants.UpdateTheme. */ +export async function resetTenantTheme(): Promise { + await apiFetch(`/api/v1/tenants/theme/reset`, { + method: "POST", + }); +} diff --git a/clients/dashboard/src/pages/settings/branding.tsx b/clients/dashboard/src/pages/settings/branding.tsx new file mode 100644 index 0000000000..4a8ebd898f --- /dev/null +++ b/clients/dashboard/src/pages/settings/branding.tsx @@ -0,0 +1,479 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Loader2, Palette, RotateCcw, Save } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ErrorBand, Field } from "@/components/list"; +import { SettingsSection } from "@/pages/settings/settings-layout"; +import { + DEFAULT_DARK_PALETTE, + DEFAULT_LIGHT_PALETTE, + getTenantTheme, + resetTenantTheme, + updateTenantTheme, + type BrandAssetsDto, + type PaletteDto, + type TenantThemeDto, +} from "@/api/tenants"; +import { ApiRequestError } from "@/lib/api-client"; +import { cn } from "@/lib/cn"; + +/** + * BrandingSettings — tenant-facing theme editor for the *current* tenant. + * + * Mirrors the operator's TenantBrandingCard (clients/admin) but drops the + * tenant targeting: the theme endpoints are current-tenant scoped, so a + * tenant admin holding Tenants.UpdateTheme edits their own branding with no + * `tenant:` header. The Branding tab only renders for holders of that + * permission (see settings-layout TABS); a direct-URL visit without it still + * mounts this page, and the API answers 403 — surfaced as an error band. + * + * Scope: palette (light + dark) + brand asset URLs. Typography and layout + * fields exist on the server DTO but stay out of the v1 editor, matching the + * admin card. + */ +const THEME_QUERY_KEY = ["tenant", "theme"] as const; + +export function BrandingSettings() { + const queryClient = useQueryClient(); + + const themeQuery = useQuery({ + queryKey: THEME_QUERY_KEY, + queryFn: getTenantTheme, + // Re-fetch on focus so a co-admin's edits are picked up without a manual + // refresh; the draft is reseeded from the fresh payload below. + refetchOnWindowFocus: true, + }); + + const [draft, setDraft] = useState(null); + + // Seed the draft whenever a fresh server payload arrives (initial load, a + // co-admin's edit picked up on focus, or our own reset). + useEffect(() => { + if (themeQuery.data) { + setDraft(themeQuery.data); + } + }, [themeQuery.data]); + + const saveMutation = useMutation({ + mutationFn: (theme: TenantThemeDto) => updateTenantTheme(theme), + onSuccess: () => { + toast.success("Branding saved"); + void queryClient.invalidateQueries({ queryKey: THEME_QUERY_KEY }); + }, + onError: (err) => toast.error("Save failed", { description: apiErr(err) }), + }); + + const resetMutation = useMutation({ + mutationFn: resetTenantTheme, + onSuccess: () => { + toast.success("Branding reset to defaults"); + void queryClient.invalidateQueries({ queryKey: THEME_QUERY_KEY }); + }, + onError: (err) => toast.error("Reset failed", { description: apiErr(err) }), + }); + + if (themeQuery.isLoading) { + return ( + +
+ + Loading branding +
+
+ ); + } + + if (themeQuery.isError) { + return ( + + + + ); + } + + if (!draft) return null; + + const dirty = + themeQuery.data && JSON.stringify(themeQuery.data) !== JSON.stringify(draft); + + const onLight = (next: Partial) => + setDraft((d) => (d ? { ...d, lightPalette: { ...d.lightPalette, ...next } } : d)); + const onDark = (next: Partial) => + setDraft((d) => (d ? { ...d, darkPalette: { ...d.darkPalette, ...next } } : d)); + const onAssets = (next: Partial) => + setDraft((d) => (d ? { ...d, brandAssets: { ...d.brandAssets, ...next } } : d)); + + const footer = ( +
+
+ {draft.isDefault && !dirty && ( + + default + + )} + {dirty && ( + + unsaved + + )} +
+
+ + +
+
+ ); + + return ( + +
+ + +
+ + +
+ + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────── +// Palette editor — color swatches paired with hex inputs +// ───────────────────────────────────────────────────────────────────────── + +const PALETTE_FIELDS: ReadonlyArray<{ key: keyof PaletteDto; label: string }> = [ + { key: "primary", label: "Primary" }, + { key: "secondary", label: "Secondary" }, + { key: "tertiary", label: "Tertiary" }, + { key: "background", label: "Background" }, + { key: "surface", label: "Surface" }, + { key: "error", label: "Error" }, + { key: "warning", label: "Warning" }, + { key: "success", label: "Success" }, + { key: "info", label: "Info" }, +]; + +function PaletteEditor({ + title, + palette, + onChange, + defaults, +}: { + title: string; + palette: PaletteDto; + onChange: (next: Partial) => void; + defaults: PaletteDto; +}) { + return ( +
+
+

+ {title} +

+ +
+
+ {PALETTE_FIELDS.map(({ key, label }) => ( + onChange({ [key]: v } as Partial)} + /> + ))} +
+
+ ); +} + +function ColorRow({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (next: string) => void; +}) { + const valid = /^#[0-9a-f]{6}$/i.test(value); + return ( +
+ {/* Color chip — clicking opens the native color picker */} + +
+
+ {label} +
+ onChange(e.target.value.toUpperCase())} + spellCheck={false} + autoComplete="off" + maxLength={9} + aria-invalid={!valid} + className={cn( + "h-7 px-2 font-mono text-[11.5px]", + !valid && + "border-[var(--color-destructive)]/60 focus-visible:ring-[var(--color-destructive)]/40", + )} + /> +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────── +// Brand assets — URL editors for logo / logo-dark / favicon +// ───────────────────────────────────────────────────────────────────────── + +function BrandAssetsEditor({ + assets, + onChange, +}: { + assets: BrandAssetsDto; + onChange: (next: Partial) => void; +}) { + return ( +
+
+

+ Brand assets +

+

+ URLs to your hosted brand assets. Upload via the Files module first, + then paste the resulting public URL here. +

+
+
+ + onChange({ logoUrl: v || null, deleteLogo: v.length === 0 }) + } + /> + + onChange({ logoDarkUrl: v || null, deleteLogoDark: v.length === 0 }) + } + /> + + onChange({ faviconUrl: v || null, deleteFavicon: v.length === 0 }) + } + /> +
+
+ ); +} + +function AssetField({ + id, + label, + value, + onChange, +}: { + id: string; + label: string; + value: string; + onChange: (next: string) => void; +}) { + return ( + +
+ onChange(e.target.value)} + placeholder="https://cdn.example.com/logo.svg" + spellCheck={false} + autoComplete="off" + className="font-mono text-[12.5px]" + /> + {value && ( + // Tiny inline preview thumbnail — reassures the tenant the URL + // points to a loadable image. Failing loads just hide via onError. + { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + className="h-9 w-9 shrink-0 rounded-lg object-contain ring-1 ring-inset ring-[var(--color-border)] bg-[var(--color-background)]" + /> + )} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────── +// Live preview — shows how primary-action buttons + surface tokens render +// ───────────────────────────────────────────────────────────────────────── + +function ThemePreview({ palette, label }: { palette: PaletteDto; label: string }) { + return ( +
+ {/* Preview header bar */} +
+ + {label} + + + live + +
+ + {/* Preview body */} +
+
+
+ + Sample tenant page + + + active + +
+

+ A short paragraph rendered with the chosen body color over the chosen + surface, on the chosen page background. Action buttons use the primary token. +

+
+ {/* Primary action */} + + Primary action + + {/* Outline secondary */} + + Secondary + + {/* Warning pill */} + + warn + + {/* Error pill */} + + error + +
+
+
+
+ ); +} + +function apiErr(err: unknown): string { + if (err instanceof ApiRequestError) { + return err.problem?.detail ?? err.problem?.title ?? err.message; + } + if (err instanceof Error) return err.message; + return "Unknown error"; +} diff --git a/clients/dashboard/src/pages/settings/settings-layout.tsx b/clients/dashboard/src/pages/settings/settings-layout.tsx index b0ea7c6696..893f09a636 100644 --- a/clients/dashboard/src/pages/settings/settings-layout.tsx +++ b/clients/dashboard/src/pages/settings/settings-layout.tsx @@ -1,6 +1,7 @@ import { NavLink, Outlet, useLocation } from "react-router-dom"; import { Bell, + Brush, ChevronRight, KeyRound, Palette, @@ -10,6 +11,7 @@ import { } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { EntityPageHeader } from "@/components/list"; +import { useAuth } from "@/auth/use-auth"; import { cn } from "@/lib/cn"; type Tab = { @@ -17,12 +19,23 @@ type Tab = { label: string; hint: string; icon: LucideIcon; + /** + * Permission required to see this tab. Tabs without a `perm` are visible to + * every authenticated tenant user; gated tabs hide when the current user + * lacks the permission, mirroring the sidebar gate in nav-data.ts so they + * never land on a page the API would reject with 403. + */ + perm?: string; }; const TABS: Tab[] = [ { to: "/settings/profile", label: "Profile", hint: "Your identity across the tenant", icon: UserRound }, { to: "/settings/security", label: "Security", hint: "Password and active sessions", icon: Shield }, { to: "/settings/appearance", label: "Appearance", hint: "Theme and visual preferences", icon: Palette }, + // Tenant-wide branding (palette + logos served on sign-in), distinct from the + // per-user Appearance prefs above. Gated on the same permission the /theme + // endpoints enforce server-side. + { to: "/settings/branding", label: "Branding", hint: "Tenant colours and logos", icon: Brush, perm: "Permissions.Tenants.UpdateTheme" }, { to: "/settings/notifications", label: "Notifications", hint: "How we reach you", icon: Bell }, { to: "/settings/api-keys", label: "API keys", hint: "Personal access tokens", icon: KeyRound }, ]; @@ -37,11 +50,16 @@ const pad2 = (n: number) => n.toString().padStart(2, "0"); */ export function SettingsLayout() { const location = useLocation(); + const { user } = useAuth(); + const perms = user?.permissions ?? []; + // Drop tabs the user can't reach, same gate the sidebar uses. `branding` + // hides for users without Tenants.UpdateTheme. + const tabs = TABS.filter((t) => !t.perm || perms.includes(t.perm)); const activeIndex = Math.max( 0, - TABS.findIndex((t) => location.pathname.startsWith(t.to)), + tabs.findIndex((t) => location.pathname.startsWith(t.to)), ); - const active = TABS[activeIndex] ?? TABS[0]; + const active = tabs[activeIndex] ?? tabs[0]; return (
@@ -81,7 +99,7 @@ export function SettingsLayout() { aria-hidden className="absolute left-[14px] top-1 bottom-1 w-px bg-[oklch(from_var(--color-border)_l_c_h_/_0.6)]" /> - {TABS.map((t, i) => { + {tabs.map((t, i) => { const num = pad2(i + 1); return (
  • @@ -151,7 +169,7 @@ export function SettingsLayout() { {/* Mobile: horizontal scroll tabs */}
    - {TABS.map(({ to, label, icon: Icon }) => ( + {tabs.map(({ to, label, icon: Icon }) => ( import("@/pages/settings/profile"), "ProfileSettings"); const SecuritySettings = lazyNamed(() => import("@/pages/settings/security"), "SecuritySettings"); +const BrandingSettings = lazyNamed( + () => import("@/pages/settings/branding"), + "BrandingSettings", +); const AppearanceSettings = lazyNamed( () => import("@/pages/settings/appearance"), "AppearanceSettings", @@ -232,6 +236,7 @@ export const router = createBrowserRouter([ { path: "profile", element: withSuspense() }, { path: "security", element: withSuspense() }, { path: "appearance", element: withSuspense() }, + { path: "branding", element: withSuspense() }, { path: "notifications", element: withSuspense() }, { path: "api-keys", element: withSuspense() }, ], diff --git a/clients/dashboard/tests/settings/branding.spec.ts b/clients/dashboard/tests/settings/branding.spec.ts new file mode 100644 index 0000000000..7bb203ad5f --- /dev/null +++ b/clients/dashboard/tests/settings/branding.spec.ts @@ -0,0 +1,140 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installShellMocks } from "../helpers/shell-mocks"; +import { mockJsonResponse, mockProblemDetails } from "../helpers/api-mocks"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; + +// A full TenantThemeDto the GET /theme mock returns. Palette values are the +// framework light/dark defaults; typography + layout are inert here (the +// editor omits them) but must be present so the DTO round-trips. +const THEME = { + lightPalette: { + primary: "#2563EB", secondary: "#0F172A", tertiary: "#6366F1", + background: "#F8FAFC", surface: "#FFFFFF", error: "#DC2626", + warning: "#F59E0B", success: "#16A34A", info: "#0284C7", + }, + darkPalette: { + primary: "#38BDF8", secondary: "#94A3B8", tertiary: "#818CF8", + background: "#0B1220", surface: "#111827", error: "#F87171", + warning: "#FBBF24", success: "#22C55E", info: "#38BDF8", + }, + brandAssets: { logoUrl: null, logoDarkUrl: null, faviconUrl: null }, + typography: { fontFamily: "Inter", headingFontFamily: "Inter", fontSizeBase: 16, lineHeightBase: 1.5 }, + layout: { borderRadius: "0.5rem", defaultElevation: 1 }, + isDefault: true, +} as const; + +/** Grant the branding permissions the tab + endpoints gate on. */ +async function grantBrandingPerms(page: Page) { + await mockJsonResponse(page, "**/api/v1/identity/permissions", [ + "Permissions.Tenants.ViewTheme", + "Permissions.Tenants.UpdateTheme", + ]); +} + +test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, TEST_USER); + await installShellMocks(page); +}); + +test.describe("settings/branding — tenant theme editor", () => { + test("renders the editor and saves an edited palette via PUT", async ({ page }) => { + await grantBrandingPerms(page); + await mockJsonResponse(page, "**/api/v1/tenants/theme", THEME); + + // Capture the PUT body while answering it 204. + let putBody: unknown = null; + await page.route("**/api/v1/tenants/theme", async (route) => { + if (route.request().method() !== "PUT") return route.fallback(); + putBody = route.request().postDataJSON(); + await route.fulfill({ status: 204, body: "" }); + }); + + await page.goto("/settings/branding"); + + // "Branding" appears in the header, the nav tab, and the section title — + // target the section's heading specifically to stay unambiguous. + await expect(page.getByRole("heading", { name: "Branding", exact: true })).toBeVisible(); + await expect(page.getByText("Light palette")).toBeVisible(); + await expect(page.getByText("Dark palette")).toBeVisible(); + + // The hex text input sits next to the color chip; target it by its value. + // Editing it flips the section to "unsaved" and enables Save. + const primaryHex = page.locator('input[value="#2563EB"]').first(); + await primaryHex.fill("#123ABC"); + await expect(page.getByText("unsaved")).toBeVisible(); + + await page.getByRole("button", { name: "Save branding" }).click(); + + await expect(page.getByText("Branding saved")).toBeVisible(); + expect(putBody).not.toBeNull(); + expect((putBody as { lightPalette: { primary: string } }).lightPalette.primary).toBe("#123ABC"); + }); + + test("reset posts to /theme/reset", async ({ page }) => { + await grantBrandingPerms(page); + await mockJsonResponse(page, "**/api/v1/tenants/theme", THEME); + + let resetCalled = false; + await page.route("**/api/v1/tenants/theme/reset", async (route) => { + resetCalled = true; + await route.fulfill({ status: 204, body: "" }); + }); + + await page.goto("/settings/branding"); + await page.getByRole("button", { name: "Reset branding to defaults" }).click(); + + await expect(page.getByText("Branding reset to defaults")).toBeVisible(); + expect(resetCalled).toBe(true); + }); + + test("hides the Branding tab for users without UpdateTheme", async ({ page }) => { + // Default permissions ([]) — no re-mock. Land on another settings tab. + await page.goto("/settings/profile"); + + await expect(page.getByRole("link", { name: /Profile/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Branding/ })).toHaveCount(0); + }); + + test("shows an error toast and keeps the draft when PUT is rejected 403", async ({ page }) => { + await grantBrandingPerms(page); + await mockJsonResponse(page, "**/api/v1/tenants/theme", THEME); + // 403 only on the PUT; GET must still succeed so the editor renders. + await page.route("**/api/v1/tenants/theme", async (route) => { + if (route.request().method() !== "PUT") return route.fallback(); + await route.fulfill({ + status: 403, + headers: { "Content-Type": "application/problem+json" }, + body: JSON.stringify({ + type: "https://httpstatuses.io/403", + title: "Forbidden", + status: 403, + detail: "You lack permission to update the theme.", + }), + }); + }); + + await page.goto("/settings/branding"); + const primaryHex = page.locator('input[value="#2563EB"]').first(); + await primaryHex.fill("#123ABC"); + await page.getByRole("button", { name: "Save branding" }).click(); + + await expect(page.getByText("Save failed")).toBeVisible(); + // Draft preserved — the edited value is still in the input. + await expect(page.locator('input[value="#123ABC"]').first()).toBeVisible(); + }); + + test("shows an error band when the theme fails to load", async ({ page }) => { + await grantBrandingPerms(page); + await mockProblemDetails(page, "**/api/v1/tenants/theme", 500, { + title: "Server error", + detail: "Could not load theme.", + }); + + await page.goto("/settings/branding"); + + // The GET retries twice (query-client retry policy) before the error state + // lands, so allow past the default 5s window. + await expect(page.getByText(/Failure/)).toBeVisible({ timeout: 12_000 }); + await expect(page.getByText("Could not load theme.")).toBeVisible(); + }); +});