Skip to content

Commit d7e1887

Browse files
feat(dashboard): tenant self-service branding editor
The backend already exposes current-tenant theme endpoints (GET/PUT /api/v1/tenants/theme, POST .../theme/reset) gated on Tenants.ViewTheme (IsBasic) + Tenants.UpdateTheme (tenant-assignable, not root-only) — so a tenant admin is meant to manage their own branding — but the only UI lived in the admin app, targeting other tenants via the `tenant:` header override. The dashboard had no way for a tenant to edit its own brand. Adds a Settings > Branding tab (dashboard) for the current tenant: - New api/tenants.ts: getTenantTheme/updateTenantTheme/resetTenantTheme for the signed-in tenant (no `tenant:` header — the server resolves it from the token). Types + default palettes mirror the admin client (the two Vite apps duplicate by design; no cross-app import). - New pages/settings/branding.tsx: light + dark palette editors (swatch + hex, validated), brand-asset URL editors with inline preview, a live theme preview, and save/reset with a dirty indicator. Ported from the admin branding card, adapted to the current tenant and the dashboard's design language. - settings-layout.tsx: the Branding tab renders only for holders of Tenants.UpdateTheme, mirroring the sidebar permission gate in nav-data.ts. - routes.tsx: lazy /settings/branding route. - Playwright coverage (route-mocked): renders + saves via PUT, reset via POST, tab hidden without the permission, PUT 403 keeps the draft, GET 500 shows an error band. Scope matches the admin card's v1: palette + brand assets (typography/layout on the DTO are intentionally omitted).
1 parent 71a1c47 commit d7e1887

5 files changed

Lines changed: 747 additions & 4 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { apiFetch } from "@/lib/api-client";
2+
3+
// ─────────────────────────────────────────────────────────────────────────
4+
// Tenant theme / branding
5+
//
6+
// The theme endpoints are CURRENT-TENANT scoped server-side — they read the
7+
// request's tenant (resolved from the caller's token) and act on that row.
8+
// Unlike the admin app, the dashboard never targets a *different* tenant, so
9+
// we send NO `tenant:` header: every call operates on the signed-in tenant.
10+
//
11+
// Types mirror clients/admin/src/api/tenants.ts (the two Vite apps duplicate
12+
// by design — no cross-app imports). Keep the shapes in sync if the server
13+
// DTO changes. Typography + layout exist on the server DTO but the editor
14+
// omits them (parity with the admin card's v1 scope).
15+
// ─────────────────────────────────────────────────────────────────────────
16+
17+
export type PaletteDto = {
18+
primary: string;
19+
secondary: string;
20+
tertiary: string;
21+
background: string;
22+
surface: string;
23+
error: string;
24+
warning: string;
25+
success: string;
26+
info: string;
27+
};
28+
29+
export type BrandAssetsDto = {
30+
logoUrl?: string | null;
31+
logoDarkUrl?: string | null;
32+
faviconUrl?: string | null;
33+
deleteLogo?: boolean;
34+
deleteLogoDark?: boolean;
35+
deleteFavicon?: boolean;
36+
};
37+
38+
export type TypographyDto = {
39+
fontFamily: string;
40+
headingFontFamily: string;
41+
fontSizeBase: number;
42+
lineHeightBase: number;
43+
};
44+
45+
export type LayoutDto = {
46+
borderRadius: string;
47+
defaultElevation: number;
48+
};
49+
50+
export type TenantThemeDto = {
51+
lightPalette: PaletteDto;
52+
darkPalette: PaletteDto;
53+
brandAssets: BrandAssetsDto;
54+
typography: TypographyDto;
55+
layout: LayoutDto;
56+
isDefault: boolean;
57+
};
58+
59+
export const DEFAULT_LIGHT_PALETTE: PaletteDto = {
60+
primary: "#2563EB",
61+
secondary: "#0F172A",
62+
tertiary: "#6366F1",
63+
background: "#F8FAFC",
64+
surface: "#FFFFFF",
65+
error: "#DC2626",
66+
warning: "#F59E0B",
67+
success: "#16A34A",
68+
info: "#0284C7",
69+
};
70+
71+
export const DEFAULT_DARK_PALETTE: PaletteDto = {
72+
primary: "#38BDF8",
73+
secondary: "#94A3B8",
74+
tertiary: "#818CF8",
75+
background: "#0B1220",
76+
surface: "#111827",
77+
error: "#F87171",
78+
warning: "#FBBF24",
79+
success: "#22C55E",
80+
info: "#38BDF8",
81+
};
82+
83+
/** Fetch the current tenant's theme. Caller needs Tenants.ViewTheme (IsBasic). */
84+
export async function getTenantTheme(): Promise<TenantThemeDto> {
85+
return apiFetch<TenantThemeDto>(`/api/v1/tenants/theme`);
86+
}
87+
88+
/** Save the current tenant's theme. Caller needs Tenants.UpdateTheme. */
89+
export async function updateTenantTheme(theme: TenantThemeDto): Promise<void> {
90+
await apiFetch<void>(`/api/v1/tenants/theme`, {
91+
method: "PUT",
92+
body: JSON.stringify(theme),
93+
});
94+
}
95+
96+
/** Reset the current tenant's theme to framework defaults. Needs Tenants.UpdateTheme. */
97+
export async function resetTenantTheme(): Promise<void> {
98+
await apiFetch<void>(`/api/v1/tenants/theme/reset`, {
99+
method: "POST",
100+
});
101+
}

0 commit comments

Comments
 (0)