From 1567c81d977d21abe4ada82587235badda381d93 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 03:41:11 +0530 Subject: [PATCH] fix(admin,multitenancy): users/roles crash, health probes, toast, demo provisioning - admin: unwrap the now-paged roles response in listRoles so the Users role filter and the Roles registry stop throwing "data?.map is not a function". Update Playwright mocks to the real paged shape so the drift is caught. - admin: proxy /health in the Vite dev server so the Health page's /health/live and /health/ready probes reach the API instead of 404ing. - admin: replace the bare toast with a dashboard-aligned FshToaster (tinted icon, accent stripe, animation) and lift the description color toward the foreground for readability. - multitenancy: DemoSeeder records a completed TenantProvisioning row for demo tenants (acme/globex); the tenant detail page now treats a 404 provisioning status as a neutral "Not tracked" state instead of a red FAILURE. Co-Authored-By: Claude Opus 4.7 (1M context) --- clients/admin/src/App.tsx | 59 +++-- clients/admin/src/api/roles.ts | 8 +- clients/admin/src/pages/tenants/detail.tsx | 28 ++- clients/admin/src/styles/globals.css | 201 ++++++++++++++++++ clients/admin/tests/roles/roles.spec.ts | 9 +- clients/admin/tests/users/users.spec.ts | 3 +- clients/admin/vite.config.ts | 1 + .../DemoSeed/DemoSeeder.cs | 34 +++ 8 files changed, 317 insertions(+), 26 deletions(-) diff --git a/clients/admin/src/App.tsx b/clients/admin/src/App.tsx index 7de541332b..f8b80fc335 100644 --- a/clients/admin/src/App.tsx +++ b/clients/admin/src/App.tsx @@ -2,10 +2,11 @@ import { Suspense } from "react"; import { RouterProvider } from "react-router-dom"; import { QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "sonner"; +import { AlertCircle, AlertTriangle, CheckCircle2, Info, Loader2 } from "lucide-react"; import { queryClient } from "@/lib/query-client"; import { AuthProvider } from "@/auth/auth-context"; import { RealtimeProvider } from "@/realtime/realtime-context"; -import { ThemeProvider } from "@/components/theme/theme-provider"; +import { ThemeProvider, useTheme } from "@/components/theme/theme-provider"; import { router } from "@/routes"; export function App() { @@ -29,23 +30,49 @@ export function App() { - + ); } + +/** + * Console toaster — a refined card with a per-type tone (left accent stripe + + * tinted icon chip), display-face title, and a body description lifted toward + * the foreground so it stays readable on the near-black dark surface. Theme is + * sourced from the in-app ThemeProvider (not sonner's "system") so the toast + * tracks the console's own light/dark toggle, not the OS preference. All + * surface styling lives in globals.css under the `.fsh-toast` selectors. + */ +function FshToaster() { + const { theme } = useTheme(); + return ( + , + error: , + warning: , + info: , + loading: , + }} + toastOptions={{ + duration: 4200, + classNames: { + toast: "fsh-toast", + title: "fsh-toast-title", + description: "fsh-toast-description", + closeButton: "fsh-toast-close", + actionButton: "fsh-toast-action", + cancelButton: "fsh-toast-cancel", + }, + }} + /> + ); +} diff --git a/clients/admin/src/api/roles.ts b/clients/admin/src/api/roles.ts index e5335d7359..d7d0e5b3cf 100644 --- a/clients/admin/src/api/roles.ts +++ b/clients/admin/src/api/roles.ts @@ -21,8 +21,12 @@ export type UpdateRolePermissionsInput = { const ROOT = "/api/v1/identity"; -export function listRoles(): Promise { - return apiFetch(`${ROOT}/roles`); +export async function listRoles(): Promise { + // The endpoint is paged (`PagedResponse` → `{ items, … }`), but every + // caller here wants the flat list. Unwrap defensively so a bare array still works. + const result = await apiFetch(`${ROOT}/roles`); + if (Array.isArray(result)) return result; + return result.items ?? []; } export function getRole(id: string): Promise { diff --git a/clients/admin/src/pages/tenants/detail.tsx b/clients/admin/src/pages/tenants/detail.tsx index 5f529434fb..fbc9cc66fc 100644 --- a/clients/admin/src/pages/tenants/detail.tsx +++ b/clients/admin/src/pages/tenants/detail.tsx @@ -48,8 +48,16 @@ export function TenantDetailPage() { queryKey: ["tenant", id, "provisioning"], queryFn: () => getTenantProvisioningStatus(id), enabled: !!id, - // Poll while provisioning is in flight; stop once terminal. + // A 404 means this tenant was never run through the provisioning pipeline + // (e.g. demo/directly-created tenants). That's a terminal "not tracked" + // state, not a transient failure — don't retry or poll it. + retry: (failureCount, err) => + !(err instanceof ApiRequestError && err.status === 404) && failureCount < 3, + // Poll while provisioning is in flight; stop once terminal (or not tracked). refetchInterval: (query) => { + if (query.state.error instanceof ApiRequestError && query.state.error.status === 404) { + return false; + } const status = query.state.data?.status; if (status === "Completed" || status === "Failed") return false; return 2000; @@ -77,6 +85,9 @@ export function TenantDetailPage() { const tenant = tenantQuery.data; const provisioning = provisioningQuery.data; + const provisioningNotTracked = + provisioningQuery.error instanceof ApiRequestError && + provisioningQuery.error.status === 404; return (
@@ -200,7 +211,11 @@ export function TenantDetailPage() { currentStep={provisioning?.currentStep ?? undefined} errorBody={provisioning?.error ?? undefined} loading={provisioningQuery.isLoading} - error={provisioningQuery.error} + // A 404 isn't an error to surface — it just means this tenant + // was never run through the pipeline. Swallow it here and let + // the panel render its neutral "not tracked" state instead. + error={provisioningNotTracked ? undefined : provisioningQuery.error} + notTracked={provisioningNotTracked} onRetry={() => retryMutation.mutate()} retryPending={retryMutation.isPending} /> @@ -240,6 +255,7 @@ function ProvisioningPanel({ errorBody, loading, error, + notTracked = false, onRetry, retryPending, }: { @@ -249,10 +265,11 @@ function ProvisioningPanel({ errorBody?: string; loading: boolean; error: unknown; + notTracked?: boolean; onRetry: () => void; retryPending: boolean; }) { - const overall = status ?? (loading ? "Loading" : "Unknown"); + const overall = notTracked ? "Not tracked" : status ?? (loading ? "Loading" : "Unknown"); const overallVariant = status === "Completed" ? "success" @@ -286,6 +303,11 @@ function ProvisioningPanel({

Loading

+ ) : notTracked ? ( +

+ This tenant wasn't created through the provisioning pipeline, so there's no run + history to show. Tenants created via the console report their seed/migrate steps here. +

) : steps.length === 0 ? (

No provisioning runs recorded. diff --git a/clients/admin/src/styles/globals.css b/clients/admin/src/styles/globals.css index 3813fac897..041a5cff27 100644 --- a/clients/admin/src/styles/globals.css +++ b/clients/admin/src/styles/globals.css @@ -698,3 +698,204 @@ .dark .mono-tone-1 { background: oklch(0.42 0 0); color: oklch(0.97 0 0); } .dark .mono-tone-2 { background: oklch(0.86 0 0); color: oklch(0.18 0 0); } .dark .mono-tone-3 { background: oklch(0.96 0 0); color: oklch(0.18 0 0); } + +/* ============================================================================ + Sonner toasts — console card with a per-type tone. Left accent stripe + + tinted icon chip carry the semantic; the description is lifted toward the + foreground (not pure muted) so it stays legible on the near-black surface. + Class names are wired in App.tsx's toastOptions.classNames. + ========================================================================== */ + +/* Per-type tone, carried via a custom property. */ +[data-sonner-toaster] [data-sonner-toast].fsh-toast { --fsh-toast-tone: var(--color-foreground); } +[data-sonner-toast][data-type="success"].fsh-toast { --fsh-toast-tone: var(--color-success); } +[data-sonner-toast][data-type="error"].fsh-toast { --fsh-toast-tone: var(--color-destructive); } +[data-sonner-toast][data-type="warning"].fsh-toast { --fsh-toast-tone: var(--color-warning); } +[data-sonner-toast][data-type="info"].fsh-toast { --fsh-toast-tone: var(--color-info); } +[data-sonner-toast][data-type="loading"].fsh-toast { --fsh-toast-tone: var(--color-primary); } + +/* Base shell. */ +[data-sonner-toaster] [data-sonner-toast].fsh-toast { + position: relative; + display: flex !important; + align-items: flex-start; + gap: 12px; + width: 100%; + min-width: 340px; + max-width: 400px; + padding: 13px 16px 13px 18px; + + background: var(--color-card); + color: var(--color-foreground) !important; + border: 1px solid var(--color-border-strong); + border-radius: 12px; + overflow: hidden; + font-family: var(--font-sans); + + box-shadow: + 0 1px 0 0 oklch(1 0 0 / 0.5) inset, + 0 1px 2px oklch(0 0 0 / 0.05), + 0 10px 28px -10px oklch(0 0 0 / 0.18); +} +.dark [data-sonner-toaster] [data-sonner-toast].fsh-toast { + box-shadow: + 0 1px 0 0 oklch(1 0 0 / 0.04) inset, + 0 1px 2px oklch(0 0 0 / 0.45), + 0 12px 32px -10px oklch(0 0 0 / 0.6); +} + +/* Left accent stripe — 3px marker inset from the rounded corners. */ +[data-sonner-toast].fsh-toast::before { + content: ""; + position: absolute; + left: 0; + top: 10px; + bottom: 10px; + width: 3px; + border-radius: 0 3px 3px 0; + background: var(--fsh-toast-tone); +} + +/* Content column (sonner wraps title + description in [data-content]). */ +[data-sonner-toast].fsh-toast > [data-content] { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; + padding-top: 1px; +} + +/* Icon chip — soft tinted square holding the Lucide glyph. */ +[data-sonner-toast].fsh-toast [data-icon] { + flex-shrink: 0; + display: inline-flex !important; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + margin: 1px 0 0 0 !important; + border-radius: 8px; + background: color-mix(in oklab, var(--fsh-toast-tone) 13%, var(--color-card)); + color: var(--fsh-toast-tone); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--fsh-toast-tone) 24%, transparent); +} +[data-sonner-toast][data-type="loading"].fsh-toast [data-icon] { + background: color-mix(in oklab, var(--color-primary) 10%, var(--color-card)); +} +[data-sonner-toast].fsh-toast .fsh-toast-glyph { + width: 18px; + height: 18px; + color: var(--fsh-toast-tone); +} +[data-sonner-toast].fsh-toast .fsh-toast-glyph-spin { + animation: fsh-toast-glyph-spin 900ms linear infinite; +} +@keyframes fsh-toast-glyph-spin { to { transform: rotate(360deg); } } + +/* Title — display face, tight. */ +[data-sonner-toast].fsh-toast .fsh-toast-title { + font-family: var(--font-display); + font-size: 14px; + font-weight: 600; + line-height: 1.35; + letter-spacing: -0.015em; + color: var(--color-foreground); + margin: 0; +} +[data-sonner-toast].fsh-toast .fsh-toast-title::before { display: none !important; } + +/* Description — lifted toward foreground for readability (the fix). */ +[data-sonner-toast].fsh-toast .fsh-toast-description { + font-family: var(--font-sans); + font-size: 12.5px; + font-weight: 400; + line-height: 1.5; + color: color-mix(in oklab, var(--color-foreground) 62%, var(--color-muted-foreground)) !important; + margin: 0; +} + +/* Close — hover-reveal X pinned top-right. */ +[data-sonner-toast].fsh-toast .fsh-toast-close { + position: absolute !important; + top: 9px !important; + right: 9px !important; + left: auto !important; + width: 20px !important; + height: 20px !important; + padding: 0 !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + border-radius: 6px !important; + background: transparent !important; + border: 1px solid transparent !important; + color: var(--color-muted-foreground) !important; + opacity: 0; + transform: none !important; + cursor: pointer; + transition: opacity 150ms ease, background-color 150ms ease, color 150ms ease; +} +[data-sonner-toast].fsh-toast:hover .fsh-toast-close, +[data-sonner-toast].fsh-toast:focus-within .fsh-toast-close { opacity: 1; } +[data-sonner-toast].fsh-toast .fsh-toast-close:hover { + background: var(--color-muted) !important; + color: var(--color-foreground) !important; +} +[data-sonner-toast].fsh-toast .fsh-toast-close svg { width: 11px; height: 11px; } + +/* Action + cancel buttons. */ +[data-sonner-toast].fsh-toast .fsh-toast-action { + margin-left: auto; + margin-top: 6px; + padding: 6px 12px !important; + font-family: var(--font-sans) !important; + font-size: 12px !important; + font-weight: 600 !important; + letter-spacing: 0.01em; + border-radius: 8px !important; + background: var(--color-foreground) !important; + color: var(--color-background) !important; + border: none !important; + cursor: pointer; + transition: transform 120ms ease, opacity 120ms ease; +} +[data-sonner-toast].fsh-toast .fsh-toast-action:hover { + opacity: 0.9; + transform: translateY(-1px); +} +[data-sonner-toast].fsh-toast .fsh-toast-cancel { + margin-top: 6px; + padding: 6px 12px !important; + font-size: 12px !important; + font-weight: 500 !important; + border-radius: 8px !important; + background: transparent !important; + color: var(--color-muted-foreground) !important; + border: 1px solid var(--color-border) !important; +} + +/* Entrance / exit. */ +[data-sonner-toaster][data-y-position="top"] [data-sonner-toast].fsh-toast[data-mounted="true"] { + animation: fsh-toast-enter 300ms var(--ease-out-cubic) both; +} +@keyframes fsh-toast-enter { + 0% { opacity: 0; transform: translateX(18px) scale(0.98); } + 100% { opacity: 1; transform: translateX(0) scale(1); } +} +[data-sonner-toaster] [data-sonner-toast].fsh-toast[data-removed="true"] { + animation: fsh-toast-exit 180ms var(--ease-out-cubic) forwards; +} +@keyframes fsh-toast-exit { + from { opacity: 1; transform: translateX(0) scale(1); } + to { opacity: 0; transform: translateX(14px) scale(0.98); } +} + +@media (prefers-reduced-motion: reduce) { + [data-sonner-toast].fsh-toast, + [data-sonner-toast].fsh-toast .fsh-toast-glyph-spin { animation: none !important; } + [data-sonner-toaster] [data-sonner-toast].fsh-toast[data-mounted="true"] { + animation: fsh-toast-fade-in 180ms var(--ease-out-cubic) both; + } + @keyframes fsh-toast-fade-in { from { opacity: 0; } to { opacity: 1; } } +} diff --git a/clients/admin/tests/roles/roles.spec.ts b/clients/admin/tests/roles/roles.spec.ts index ad26041910..fbca6a91d5 100644 --- a/clients/admin/tests/roles/roles.spec.ts +++ b/clients/admin/tests/roles/roles.spec.ts @@ -1,9 +1,10 @@ import { expect, test } from "@playwright/test"; import { mockJsonResponse } from "../helpers/api-mocks"; import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; -import { installAdminShellMocks, ADMIN_PERMS } from "../helpers/shell-mocks"; +import { installAdminShellMocks, ADMIN_PERMS, paged } from "../helpers/shell-mocks"; -// listRoles returns a bare array (not paged). System roles Admin/Basic sort first. +// listRoles hits the paged roles endpoint (`PagedResponse`) and unwraps +// `.items`. System roles Admin/Basic sort first. const ROLES = [ { id: "role-manager", name: "Manager", description: "Manages a team" }, { id: "role-admin", name: "Admin", description: "Full system access" }, @@ -17,7 +18,7 @@ test.beforeEach(async ({ page }) => { test.describe("roles list", () => { test("renders the Roles heading and a role row from the mock data", async ({ page }) => { - await mockJsonResponse(page, "**/api/v1/identity/roles", ROLES); + await mockJsonResponse(page, "**/api/v1/identity/roles", paged(ROLES)); await page.goto("/roles"); @@ -33,7 +34,7 @@ test.describe("roles list", () => { }); test("shows the empty state when no roles are defined", async ({ page }) => { - await mockJsonResponse(page, "**/api/v1/identity/roles", []); + await mockJsonResponse(page, "**/api/v1/identity/roles", paged([])); await page.goto("/roles"); diff --git a/clients/admin/tests/users/users.spec.ts b/clients/admin/tests/users/users.spec.ts index 5706364845..cb313e3501 100644 --- a/clients/admin/tests/users/users.spec.ts +++ b/clients/admin/tests/users/users.spec.ts @@ -35,7 +35,8 @@ test.beforeEach(async ({ page }) => { await seedAuthedSession(page, { ...TEST_USER, permissions: [...ADMIN_PERMS] }); await installAdminShellMocks(page); // Role filter dropdown source — page-specific, registered after shell. - await mockJsonResponse(page, "**/api/v1/identity/roles", ROLES); + // The roles endpoint is paged (`PagedResponse`); listRoles unwraps `.items`. + await mockJsonResponse(page, "**/api/v1/identity/roles", paged(ROLES)); }); test.describe("users directory list", () => { diff --git a/clients/admin/vite.config.ts b/clients/admin/vite.config.ts index 4e6b131901..3205b42489 100644 --- a/clients/admin/vite.config.ts +++ b/clients/admin/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig(({ mode }) => { strictPort: true, proxy: { "/api": { target: apiBase, changeOrigin: true, secure: false }, + "/health": { target: apiBase, changeOrigin: true, secure: false }, "/openapi": { target: apiBase, changeOrigin: true, secure: false }, "/scalar": { target: apiBase, changeOrigin: true, secure: false }, }, diff --git a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs index d41365c8fc..a4b13711bd 100644 --- a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs +++ b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs @@ -12,6 +12,7 @@ using FSH.Modules.Identity.Domain; using FSH.Modules.Multitenancy.Contracts; using FSH.Modules.Multitenancy.Data; +using FSH.Modules.Multitenancy.Provisioning; using FSH.Modules.Tickets.Contracts.Dtos; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; @@ -126,7 +127,40 @@ private async Task EnsureDemoTenantsExistAsync(CancellationToken cancellationTok // initializers are no-ops in the current design. await tenantService.MigrateTenantAsync(existing, cancellationToken).ConfigureAwait(false); await tenantService.SeedTenantAsync(existing, cancellationToken).ConfigureAwait(false); + + await EnsureProvisioningRecordAsync(tenantDb, demo.Id, cancellationToken).ConfigureAwait(false); + } + } + + ///

+ /// Demo tenants are migrated + seeded inline above, bypassing the provisioning + /// pipeline — so no row exists and the admin + /// Provisioning panel would 404. Record a completed run (all steps done) so the + /// panel shows a real "Completed" history instead. Idempotent: skips if a row + /// already exists for the tenant. + /// + private static async Task EnsureProvisioningRecordAsync(TenantDbContext tenantDb, string tenantId, CancellationToken cancellationToken) + { + var alreadyTracked = await tenantDb.Set() + .AnyAsync(p => p.TenantId == tenantId, cancellationToken) + .ConfigureAwait(false); + if (alreadyTracked) + { + return; } + + var provisioning = new TenantProvisioning(tenantId, Guid.NewGuid().ToString()); + foreach (var step in Enum.GetValues()) + { + var stepEntity = new TenantProvisioningStep(provisioning.Id, step); + stepEntity.MarkRunning(); + stepEntity.MarkCompleted(); + provisioning.Steps.Add(stepEntity); + } + provisioning.MarkCompleted(); + + tenantDb.Add(provisioning); + await tenantDb.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } // ─── Users + roles ─────────────────────────────────────────────────