Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 43 additions & 16 deletions clients/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -29,23 +30,49 @@ export function App() {
<RouterProvider router={router} />
</Suspense>
</RealtimeProvider>
<Toaster
position="top-right"
theme="system"
closeButton
toastOptions={{
className: "font-sans text-sm",
style: {
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border-strong)",
backgroundColor: "var(--color-surface-2)",
color: "var(--color-foreground)",
fontFamily: "var(--font-sans)",
},
}}
/>
<FshToaster />
</AuthProvider>
</QueryClientProvider>
</ThemeProvider>
);
}

/**
* 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 (
<Toaster
position="top-right"
closeButton
theme={theme}
gap={10}
expand
visibleToasts={4}
icons={{
success: <CheckCircle2 className="fsh-toast-glyph" strokeWidth={2.25} />,
error: <AlertCircle className="fsh-toast-glyph" strokeWidth={2.25} />,
warning: <AlertTriangle className="fsh-toast-glyph" strokeWidth={2.25} />,
info: <Info className="fsh-toast-glyph" strokeWidth={2.25} />,
loading: <Loader2 className="fsh-toast-glyph fsh-toast-glyph-spin" strokeWidth={2.25} />,
}}
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",
},
}}
/>
);
}
8 changes: 6 additions & 2 deletions clients/admin/src/api/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ export type UpdateRolePermissionsInput = {

const ROOT = "/api/v1/identity";

export function listRoles(): Promise<RoleDto[]> {
return apiFetch<RoleDto[]>(`${ROOT}/roles`);
export async function listRoles(): Promise<RoleDto[]> {
// The endpoint is paged (`PagedResponse<RoleDto>` → `{ items, … }`), but every
// caller here wants the flat list. Unwrap defensively so a bare array still works.
const result = await apiFetch<RoleDto[] | { items?: RoleDto[] }>(`${ROOT}/roles`);
if (Array.isArray(result)) return result;
return result.items ?? [];
}

export function getRole(id: string): Promise<RoleDto> {
Expand Down
28 changes: 25 additions & 3 deletions clients/admin/src/pages/tenants/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="space-y-8">
Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -240,6 +255,7 @@ function ProvisioningPanel({
errorBody,
loading,
error,
notTracked = false,
onRetry,
retryPending,
}: {
Expand All @@ -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"
Expand Down Expand Up @@ -286,6 +303,11 @@ function ProvisioningPanel({
<p className="meta text-[var(--color-muted-foreground)]">
Loading<span className="caret text-[var(--color-accent-signal)]" />
</p>
) : notTracked ? (
<p className="text-sm text-[var(--color-muted-foreground)]">
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.
</p>
) : steps.length === 0 ? (
<p className="text-sm text-[var(--color-muted-foreground)]">
No provisioning runs recorded.
Expand Down
201 changes: 201 additions & 0 deletions clients/admin/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FshToaster> 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; } }
}
Loading
Loading