@@ -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 ─────────────────────────────────────────────────