From 3829c96af2837ffe016ffc6c960915173900344e Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 03:44:13 +0530 Subject: [PATCH 01/15] =?UTF-8?q?docs(spec):=20admin=20=E2=86=92=20dashboa?= =?UTF-8?q?rd=20design=20unification=20program?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole-program spec for reskinning the admin app onto the dashboard's design system (Approach A: copy/mirror). Covers end state, token reconciliation, component + page inventory, and a 4-phase plan (tokens → primitives → shell → pages), each phase verified before the next. Avatar presigned upload lands as part of Phase 4 settings. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...min-dashboard-design-unification-design.md | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-admin-dashboard-design-unification-design.md diff --git a/docs/superpowers/specs/2026-05-28-admin-dashboard-design-unification-design.md b/docs/superpowers/specs/2026-05-28-admin-dashboard-design-unification-design.md new file mode 100644 index 0000000000..13d95794a6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-admin-dashboard-design-unification-design.md @@ -0,0 +1,212 @@ +# Admin → Dashboard Design Unification + +- **Date:** 2026-05-28 +- **Status:** Draft (awaiting review) +- **Author:** Claude (with Mukesh) +- **Approach:** A — copy/mirror the dashboard's design system into the admin app +- **Tracking branch:** `feat/admin-dashboard-unification` + +## 1. Summary + +Reskin the operator app (`clients/admin`) so it adopts the tenant app's +(`clients/dashboard`) design system **wholesale** — tokens, component library, +layout chrome, and page structures — until the two apps are visually +indistinguishable in look-and-feel (differing only in their feature sets and +navigation targets). + +This is a **multi-phase program**, not a single change. Each phase is built, +linted, type-checked, and visually eyeballed before the next begins. + +## 2. Context + +The repo ships two React 19 + Vite 7 SPAs that already share an architecture +(TanStack Query v5, React Router 7, Radix + Tailwind v4 shadcn-style, a +hand-written `apiFetch`, runtime `/config.json`). They deliberately diverged in +*visual language*: + +- **admin** — "Console": an editorial-terminal aesthetic. `// CONSOLE` + masthead, `\ SECTION` mono-caps markers, sharp dark-first chrome, a + chartreuse-lime (`--signal-*`) accent, a grid-texture canvas, and a + documentation-aside form layout (`FormShell`/`FormSection` with a fixed 18rem + left rail). +- **dashboard** — a warmer, calmer editorial card system: header-bar section + cards (`SettingsSection`), a warm brand/saffron accent, softer surfaces, an + editorial numbered settings nav, a command palette, and space-efficient + `sm:grid-cols-2` field layouts. + +**Decision (owner):** unify on the **dashboard's** language. The admin app's +distinct console identity (masthead, `\` markers, chartreuse signal, grid +texture) is intentionally retired in favor of one consistent system. + +### Why this is worth doing +- The dashboard layout is materially more space-efficient — the admin's 18rem + description rail and width-capped single-column forms waste horizontal space + on every editor page. +- The dashboard's component set is richer and more polished (dropdown-menu, + avatar, switch, command palette, presigned `ImageInput`, a complete toast + theme). +- One language across both apps lowers cognitive load for operators who use + both and reduces design drift. + +## 3. End state ("done" looks like) + +- `clients/admin/src/styles/globals.css` is a copy of the dashboard's token + system (palette, surfaces, radii, shadows, motion, fonts, base, utilities), + adapted only where admin genuinely needs an extra token. +- `clients/admin/src/components/ui/*` matches the dashboard's component set and + styling (plus the dashboard-only components admin now needs: `avatar`, + `dropdown-menu`, `switch`). +- The admin app shell (sidebar + topbar) reads as the dashboard's shell, not + the `// CONSOLE` masthead. +- Every admin page uses the dashboard's section-card / field-grid layout + vocabulary. No page still uses `FormShell`/`FormSection`'s 18rem rail. +- Avatar upload uses the dashboard's presigned `ImageInput` (fixes the current + data-URL-vs-2048-cap bug as a by-product). +- `npm run build` (tsc + vite) and `eslint` pass for `clients/admin`; the + Playwright suite still passes (selectors updated where markup changed). + +## 4. Approach + +**Chosen: A — copy/mirror.** Port the dashboard's tokens/components/pages into +admin file-by-file. Two independent apps that happen to look identical. Simplest +mental model, no monorepo/workspace tooling, each app still builds and deploys +on its own. + +**Rejected for now:** +- **B — shared design-system package.** Single source of truth, zero drift, but + a large upfront refactor (workspace tooling, move components, rewire imports + in *both* apps) before any visible result. +- **C — hybrid (copy now, extract later).** Reasonable, but the owner chose a + clean copy/mirror; extraction can be reconsidered if maintaining two copies + becomes painful (see Open Questions). + +**Consequence of A:** any future design change must be applied to both apps. +Accepted. + +## 5. Non-goals + +- No backend/API changes (except none — purely `clients/admin`). +- No change to admin's **routes, permissions, RouteGuards, or data layer** + (`src/api/*`, query keys). This is a visual/structural reskin only. +- Not touching `clients/dashboard` (it is the reference, not a target). +- Not introducing new features to admin; pages keep their current capabilities. +- Not adopting dashboard-only *features* that admin has no backend for (e.g. + command palette is optional and deferred — see Phase 3). + +## 6. Inventory + +### 6.1 Token layer +Both apps expose the same semantic layer via `@theme inline` +(`--color-foreground`, `--color-card`, `--color-border`, `--color-primary`, +`--color-muted-foreground`, `--surface-1..4`, `--color-success/warning/info/ +destructive`, `--ease-out-cubic`, `--font-display/mono`, …). This shared +surface is what makes copy/mirror safe — components keep referencing the same +names; only the *values* change. + +| | admin (current) | dashboard (target) | +|---|---|---| +| Accent scale | `--signal-*` (chartreuse) | `--brand-*` + `--color-saffron` (warm) | +| Neutrals | `--neutral-*` hue 240 | dashboard neutrals (untinted; see memory) | +| Extras | `--accent-signal*`, `--grid-alpha` | `--color-overlay`, `--color-primary-hover`, `--chart-*`, `--font-feature-*` | + +**Reconciliation:** copy the dashboard's `:root`/`.dark`/`@theme inline` +blocks verbatim, then add the *handful* of admin-only tokens still referenced by +not-yet-migrated admin code so nothing breaks mid-migration; remove them as the +referencing components migrate. + +### 6.2 Components + +| Group | admin has | dashboard has | action | +|---|---|---|---| +| `ui/` | badge, button, card, dialog, input, label, skeleton, table | + avatar, dropdown-menu, switch (no table) | port dashboard versions; **keep** admin's `table` (dashboard has none), restyled to tokens | +| layout | app-shell, sidebar, topbar, mobile-nav, nav-items, sidebar-content | layout/* (dashboard shell) | rebuild admin shell on the dashboard pattern | +| list | `FormShell`/`FormSection`/`FormActions`, `PageHeader`, `SectionRule` | `SettingsSection`, `Field`, `EntityPageHeader`, list primitives | replace admin form primitives with dashboard equivalents | +| file | — | `ImageInput` + `use-file-upload` + `api/files` | port for avatar + any image fields | + +### 6.3 Pages (admin → dashboard analog) +audits (list/detail) · auth (confirm-email/forgot-password/reset-password) · +billing (invoices/plans/layout) · dashboard(overview) · health · impersonation · +login · not-found · notifications/inbox · roles (list/create/detail) · settings +(layout/profile/security/sessions/appearance) · tenants (list/create/detail) · +users (list/create/detail) · webhooks (list/detail). + +The dashboard has close analogs for most (identity↔roles/users, settings/*, +health, audits, login, auth/*, overview↔dashboard). Admin-only pages (tenants, +impersonation, billing-plan authoring, webhooks) get the *vocabulary* applied +without a 1:1 source page. + +## 7. The program (phases) + +> Ordering is bottom-up: foundation first so each later layer renders correctly +> against the new base. Every phase ends green (build + lint + type-check) and +> is committed before the next starts. + +### Phase 1 — Tokens & global styles +Port the dashboard's `globals.css` into admin (palette, surfaces, radii, +shadows, motion, fonts, base layer, shared utilities, sonner block). Reconcile +token names (§6.1). Drop admin's grid-texture canvas + `// CONSOLE` chrome +styling. **Exit:** admin builds; pages render with the new palette (some still +structurally old — acceptable mid-program). + +### Phase 2 — Shared UI primitives + form layout +Port/align `ui/*` (button, input, badge, card, dialog, label, skeleton; add +avatar, dropdown-menu, switch; restyle `table`). Replace `FormShell`/ +`FormSection`/`FormActions` usages with `SettingsSection` + `Field`. Port the +`list/` primitives and `EntityPageHeader`. **Exit:** primitives match the +dashboard; a sample page (Settings/Profile) fully converted as the reference. + +### Phase 3 — App shell +Rebuild `components/layout/*` (sidebar, topbar, mobile-nav) on the dashboard's +shell. Retire the `// CONSOLE` masthead and `platform · administration · +interface` footer. Command palette: **deferred/optional** (port only if low +effort; not required for "done"). **Exit:** navigating admin feels like the +dashboard shell. + +### Phase 4 — Pages (sub-phased, each its own commit) +Apply the section-card/field-grid vocabulary page-group by page-group, in +rising blast-radius order: +1. **settings/** (profile + **presigned avatar via `ImageInput`**, security, + sessions, appearance, layout) +2. **roles/** + **users/** (list/create/detail) +3. **tenants/** (list/create/detail — reuses the already-fixed provisioning UI) +4. **billing/**, **webhooks/**, **audits/**, **notifications/**, **health/**, + **impersonation/** +5. **auth/** + **login** + **not-found** + **dashboard(overview)** + +**Exit:** no page references retired primitives; Playwright selectors updated. + +## 8. Verification (every phase) +- `cd clients/admin && npm run build` (tsc -b + vite build) — must pass + (`TreatWarningsAsErrors` analog: tsc is strict). +- `npx eslint .` — clean. +- `npx playwright test` for the touched suites — green (update route-mocked + selectors where markup changed; mocks themselves shouldn't need changes since + the data layer is untouched). +- Manual: run the admin dev server, eyeball the migrated pages in light + dark. + +## 9. Risks & mitigations +- **Mid-migration breakage** (a component restyled before its consumers) → + keep admin-only tokens alive until their consumers migrate (§6.1); phases are + bottom-up so consumers migrate after primitives. +- **Playwright drift** — markup/class changes break selectors → update + selectors per page-group in the same commit; data mocks stay valid (data + layer untouched). +- **Scope creep into features** — strict non-goals (§5); reskin only. +- **Unrelated in-flight work** — the working tree currently carries someone + else's uncommitted chat/realtime changes; this program touches only + `clients/admin/**` (+ this spec) and never stages those files. +- **Identity loss is intentional** — the console aesthetic is being retired by + explicit owner decision; not a regression. + +## 10. Rollback +Each phase is an isolated commit on `feat/admin-dashboard-unification`. Any +phase can be reverted independently. The whole program lives behind one PR; if +abandoned, the branch is dropped with zero impact on `main` or the dashboard. + +## 11. Open questions +- **Shared package later?** If drift between the two copies becomes painful, + revisit Approach B (extract `packages/ui`). Out of scope for this program. +- **Command palette in admin?** Deferred. Decide during Phase 3 based on effort. +- **`table` primitive** — admin has one the dashboard lacks; keep + restyle, or + replace admin's table-using pages with the dashboard's list-row pattern? + Resolve when Phase 4 reaches the first table page. From b643e0fe34b9d890e1adcde6c7839e4932cc6788 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 03:48:25 +0530 Subject: [PATCH 02/15] fix(chat): deliver live messages to channels joined after connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppHub.OnConnectedAsync only joins the channel groups a user already belonged to at connect time, so a DM/channel created — or a membership granted — after the socket is live never received channel:{id} broadcasts until a page reload. The recipient saw nothing until refresh. - AppHub: add membership-gated, idempotent JoinChannel(Guid) hub method - dashboard chat-page: invoke JoinChannel on channel open + on reconnect - FindOrCreateDm: push ChatChannelAdded to other participants' user:{id} group so a new DM appears in their channel rail without a refresh - channel-rail: handle ChatChannelAdded (was pre-registered but unhandled) - tests: JoinChannelTests (late-joiner receives; non-member rejected) - docs: realtime.md group-join model note Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/rules/realtime.md | 1 + .../dashboard/src/pages/chat/channel-rail.tsx | 14 ++ .../dashboard/src/pages/chat/chat-page.tsx | 12 + src/BuildingBlocks/Web/Realtime/AppHub.cs | 23 ++ .../FindOrCreateDmCommandHandler.cs | 22 ++ .../Tests/Chat/JoinChannelTests.cs | 230 ++++++++++++++++++ 6 files changed, 302 insertions(+) create mode 100644 src/Tests/Integration.Tests/Tests/Chat/JoinChannelTests.cs diff --git a/.agents/rules/realtime.md b/.agents/rules/realtime.md index 49574c6660..a541fb284a 100644 --- a/.agents/rules/realtime.md +++ b/.agents/rules/realtime.md @@ -6,6 +6,7 @@ `[Authorize] AppHub` mapped at **`/api/v1/realtime/hub`**. Groups: `user:{userId}`, `tenant:{tenantId}`, `channel:{channelId}`. +- **Channel-group join is connect-time + on-demand.** `OnConnectedAsync` auto-joins `user:{id}`, `tenant:{id}`, and every `channel:{id}` the user is *already* a member of. A channel that becomes relevant **after** the socket is live (a new DM, or being added to a channel) is **not** auto-joined — the client must call the membership-gated **`JoinChannel(channelId)`** hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs `OnConnectedAsync`. New-DM creation pushes `ChatChannelAdded` to each other participant's `user:{id}` group so their channel list refreshes. - **⚠️ Read the user from `Context.User`, NOT `ICurrentUser`.** `ICurrentUser` flows through `IHttpContextAccessor`, but the negotiate `HttpContext` isn't pinned to subsequent hub invocations → `ICurrentUser` returns nulls inside the hub. Use `Context.User` (the hub's `GetUserId()`/`GetTenantId()` helpers). - Broadcasts are **scoped to groups** (`tenant:{id}`, `user:{id}`, `channel:{id}`), never `Clients.All`. `PresenceChanged` goes to the tenant group. - Redis backplane is added automatically when `CachingOptions:Redis` is set (channel prefix `fsh-signalr`) — required for multi-replica. diff --git a/clients/dashboard/src/pages/chat/channel-rail.tsx b/clients/dashboard/src/pages/chat/channel-rail.tsx index d93ab858cc..b452fbe003 100644 --- a/clients/dashboard/src/pages/chat/channel-rail.tsx +++ b/clients/dashboard/src/pages/chat/channel-rail.tsx @@ -25,6 +25,7 @@ import { RealtimeStatusPill } from "@/components/realtime/realtime-status-pill"; import { cn } from "@/lib/cn"; import { useUserDisplay } from "@/lib/use-user-display"; import { usePresence } from "@/realtime/use-presence"; +import { useRealtimeEvent } from "@/realtime/realtime-context"; import { channelTitle } from "@/pages/chat/chat-utils"; /** @@ -53,12 +54,25 @@ export function ChannelRail({ const [createChannelOpen, setCreateChannelOpen] = useState(false); const [newDmOpen, setNewDmOpen] = useState(false); + const queryClient = useQueryClient(); + const channelsQuery = useQuery({ queryKey: ["chat", "my-channels"], queryFn: () => listMyChannels({ pageSize: 100 }), staleTime: 30_000, }); + // A new conversation just became relevant to us (someone DM'd us, or we were + // added to a channel). The hub pushes ChatChannelAdded to our user:{id} group — + // which every tab joins on connect — so refresh the rail to surface it without a + // reload. Opening the conversation then joins its channel:{id} group for live + // messages (see chat-page's JoinChannel invoke). + useRealtimeEvent<{ channelId: string }>( + "ChatChannelAdded", + () => void queryClient.invalidateQueries({ queryKey: ["chat", "my-channels"] }), + [queryClient], + ); + const channels = useMemo(() => channelsQuery.data ?? [], [channelsQuery.data]); const { namedChannels, dms } = useMemo(() => { diff --git a/clients/dashboard/src/pages/chat/chat-page.tsx b/clients/dashboard/src/pages/chat/chat-page.tsx index ed4f073264..b38db56258 100644 --- a/clients/dashboard/src/pages/chat/chat-page.tsx +++ b/clients/dashboard/src/pages/chat/chat-page.tsx @@ -36,6 +36,7 @@ import { TypingIndicator } from "@/pages/chat/typing-indicator"; import { channelTitle } from "@/pages/chat/chat-utils"; import { cn } from "@/lib/cn"; import { useUserDisplay } from "@/lib/use-user-display"; +import { useRealtime } from "@/realtime/realtime-context"; /** * /chat — top-level chat shell. @@ -151,6 +152,17 @@ function ActiveChannel({ const [searching, setSearching] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const messageListRef = useRef(null); + const { invoke, status } = useRealtime(); + + // Join this channel's realtime group whenever it's opened, and re-join when the + // socket (re)connects. AppHub.OnConnectedAsync only pre-joins channels that existed + // at connect time, so a DM/channel opened later — or created after the socket came + // up — wouldn't receive live messages until a reload without this. The hub method is + // membership-gated and idempotent, so calling it on every open/reconnect is safe. + useEffect(() => { + if (status !== "connected") return; + void invoke("JoinChannel", channelId); + }, [channelId, status, invoke]); // Clear ephemeral state when the user switches channels. useEffect(() => { diff --git a/src/BuildingBlocks/Web/Realtime/AppHub.cs b/src/BuildingBlocks/Web/Realtime/AppHub.cs index 092d6386ec..bab929aca5 100644 --- a/src/BuildingBlocks/Web/Realtime/AppHub.cs +++ b/src/BuildingBlocks/Web/Realtime/AppHub.cs @@ -173,6 +173,29 @@ await Clients.OthersInGroup($"channel:{channelId}") .SendAsync("ChatTypingStarted", new { channelId, userId }, Context.ConnectionAborted) .ConfigureAwait(false); } + + /// + /// Client invokes JoinChannel(channelId) when it opens a conversation. + /// only pre-joins the channels that existed — and that the user + /// was already a member of — at connect time. A DM/channel created, or a membership granted, + /// after the socket is live would otherwise never receive channel:{id} broadcasts + /// until the page reloads and a fresh connection re-enumerates memberships. This joins the group + /// on demand, gated by the same membership check used for typing. Idempotent — re-joining a group + /// you're already in is a no-op, so the client can call it freely on open and on reconnect. + /// + public async Task JoinChannel(Guid channelId) + { + var userId = GetUserId(); + if (string.IsNullOrEmpty(userId)) return; + + if (!await _membership.IsMemberAsync(channelId, userId, Context.ConnectionAborted).ConfigureAwait(false)) + { + return; + } + + await Groups.AddToGroupAsync(Context.ConnectionId, $"channel:{channelId}", Context.ConnectionAborted) + .ConfigureAwait(false); + } } internal static partial class AppHubLog diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs index 6ff5839067..1bca898711 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs @@ -1,15 +1,18 @@ using FSH.Framework.Core.Context; using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Realtime; using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; using Mediator; +using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; namespace FSH.Modules.Chat.Features.v1.Channels.FindOrCreateDm; public sealed class FindOrCreateDmCommandHandler( ChatDbContext db, + IHubContext hub, ICurrentUser currentUser) : ICommandHandler { @@ -42,6 +45,7 @@ public async ValueTask Handle(FindOrCreateDmCommand cmd, CancellationToken var dm = ChatChannel.CreateDirect(currentUserId, otherIds[0]); db.Channels.Add(dm); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await NotifyChannelAddedAsync(dm.Id, otherIds, cancellationToken).ConfigureAwait(false); return dm.Id; } @@ -51,6 +55,24 @@ public async ValueTask Handle(FindOrCreateDmCommand cmd, CancellationToken var group = ChatChannel.CreateGroupDm(allUserIds, currentUserId); db.Channels.Add(group); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await NotifyChannelAddedAsync(group.Id, otherIds, cancellationToken).ConfigureAwait(false); return group.Id; } + + /// + /// Tell every other participant's open tabs that a new conversation exists so their channel rail + /// refreshes without a reload. Targets each user's user:{id} group — always joined on connect + /// (see ) — because their live sockets aren't in the new + /// channel:{id} group yet; the client joins that on demand when it opens the conversation. + /// The caller's own rail refreshes via the mutation's onSuccess on the client. + /// + private async Task NotifyChannelAddedAsync(Guid channelId, IEnumerable otherUserIds, CancellationToken cancellationToken) + { + foreach (var uid in otherUserIds) + { + await hub.Clients.Group($"user:{uid}") + .SendAsync("ChatChannelAdded", new { channelId }, cancellationToken) + .ConfigureAwait(false); + } + } } diff --git a/src/Tests/Integration.Tests/Tests/Chat/JoinChannelTests.cs b/src/Tests/Integration.Tests/Tests/Chat/JoinChannelTests.cs new file mode 100644 index 0000000000..1167ad2a9b --- /dev/null +++ b/src/Tests/Integration.Tests/Tests/Chat/JoinChannelTests.cs @@ -0,0 +1,230 @@ +using System.Net.Http.Json; +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Chat.Contracts.v1.DTOs; +using FSH.Modules.Identity.Domain; +using Integration.Tests.Infrastructure; +using Integration.Tests.Infrastructure.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Integration.Tests.Tests.Chat; + +/// +/// Covers the JoinChannel hub method: +/// only pre-joins channels that existed (and the user was a member of) at connect time, so a channel that +/// becomes relevant *after* the socket is live needs an on-demand join or its group broadcasts never arrive +/// until the page reloads. This is the root cause of "the recipient doesn't see the message until refresh". +/// +[Collection(FshCollectionDefinition.Name)] +public sealed class JoinChannelTests +{ + private const string ChatBasePath = "/api/v1/chat"; + private const string HubPath = "/api/v1/realtime/hub"; + private static readonly TimeSpan EventTimeout = TimeSpan.FromSeconds(5); + + private readonly FshWebApplicationFactory _factory; + private readonly AuthHelper _auth; + + public JoinChannelTests(FshWebApplicationFactory factory) + { + _factory = factory; + _auth = new AuthHelper(factory); + } + + [Fact] + public async Task JoinChannel_Should_Deliver_Messages_For_A_Channel_Joined_After_Connect() + { + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var (peer, peerToken) = await RegisterAndSignInAsync(adminClient, "joiner"); + + // Channel exists, but the peer connects BEFORE being added — so OnConnectedAsync + // does not pre-join them to channel:{id}. This is the live-conversation scenario. + var channelId = await CreateChannelAsync(adminClient, Unique("Join")); + await using var peerHub = await ConnectAsync(peerToken); + using var peerInbox = new EventInbox(peerHub, "ChatMessageCreated"); + + await AddMemberAsync(adminClient, channelId, peer.Id); + + // The fix: the client joins the group on demand once the channel is open. + await peerHub.InvokeAsync("JoinChannel", channelId); + + await SendMessageAsync(adminClient, channelId, "live to a late joiner"); + + var received = await peerInbox.WaitForFirstAsync(m => m.ChannelId == channelId, EventTimeout); + received.ShouldNotBeNull("a member that joined the channel after connecting should receive live messages"); + received.Body.ShouldBe("live to a late joiner"); + } + + [Fact] + public async Task JoinChannel_Should_Not_Add_NonMembers_To_The_Group() + { + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var (_, peerToken) = await RegisterAndSignInAsync(adminClient, "outsider"); + + // Peer is never added as a member. + var channelId = await CreateChannelAsync(adminClient, Unique("Guard")); + await using var peerHub = await ConnectAsync(peerToken); + using var peerInbox = new EventInbox(peerHub, "ChatMessageCreated"); + + // Membership check must reject this — the connection stays out of the group. + await peerHub.InvokeAsync("JoinChannel", channelId); + + await SendMessageAsync(adminClient, channelId, "should not leak"); + + var received = await peerInbox.WaitForFirstAsync(m => m.ChannelId == channelId, TimeSpan.FromSeconds(2)); + received.ShouldBeNull("a non-member must not be added to the channel group by JoinChannel"); + } + + // ─── helpers ───────────────────────────────────────────────────── + + private static string Unique(string prefix) => $"chat-{prefix}-{Guid.NewGuid().ToString("N")[..8]}"; + + private async Task ConnectAsync(string accessToken) + { + var connection = new HubConnectionBuilder() + .WithUrl( + $"http://localhost{HubPath}?access_token={Uri.EscapeDataString(accessToken)}", + options => + { + options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); + options.WebSocketFactory = (_, _) => throw new NotSupportedException(); + options.SkipNegotiation = false; + options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.LongPolling; + options.Headers["tenant"] = TestConstants.RootTenantId; + }) + .Build(); + await connection.StartAsync(); + return connection; + } + + private static async Task CreateChannelAsync(HttpClient client, string name) + { + using var response = await client.PostAsJsonAsync($"{ChatBasePath}/channels", new + { + name, + description = (string?)null, + isPrivate = false, + }); + return await response.DeserializeAsync(); + } + + private static async Task AddMemberAsync(HttpClient client, Guid channelId, string userId) + { + using var response = await client.PostAsJsonAsync($"{ChatBasePath}/channels/{channelId}/members", new + { + channelId, + userIds = new[] { userId }, + }); + response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NoContent); + } + + private static async Task SendMessageAsync(HttpClient client, Guid channelId, string body) + { + using var response = await client.PostAsJsonAsync( + $"{ChatBasePath}/channels/{channelId}/messages", + new { body, parentMessageId = (Guid?)null, attachments = Array.Empty() }); + response.EnsureSuccessStatusCode(); + } + + private async Task<(UserCredentials user, string accessToken)> RegisterAndSignInAsync(HttpClient adminClient, string prefix) + { + var unique = Guid.NewGuid().ToString("N")[..8]; + var email = $"{prefix}-{unique}@example.com"; + var userName = $"{prefix}{unique}"; + const string password = "Test@1234!"; + + using var response = await adminClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/register", new + { + firstName = prefix, + lastName = "Test", + email, + userName, + password, + confirmPassword = password, + }); + response.StatusCode.ShouldBe(System.Net.HttpStatusCode.Created); + var registered = await response.DeserializeAsync(); + + // Bypass /register's email-confirmation gate. + using var scope = _factory.Services.CreateScope(); + var tenant = await scope.ServiceProvider.GetRequiredService>() + .GetAsync(TestConstants.RootTenantId); + scope.ServiceProvider.GetRequiredService().MultiTenantContext = + new MultiTenantContext(tenant); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByIdAsync(registered.UserId); + user.ShouldNotBeNull(); + if (!user!.EmailConfirmed) + { + user.EmailConfirmed = true; + (await userManager.UpdateAsync(user)).Succeeded.ShouldBeTrue(); + } + + var token = await _auth.GetTokenAsync(email, password); + return (new UserCredentials(registered.UserId, userName, email), token.AccessToken); + } + + private sealed record UserCredentials(string Id, string UserName, string Email); + + private sealed class EventInbox : IDisposable + { + private readonly System.Collections.Concurrent.ConcurrentQueue _items = new(); + private readonly System.Threading.SemaphoreSlim _signal = new(0); + private readonly IDisposable _subscription; + + public EventInbox(HubConnection connection, string eventName) + { + _subscription = connection.On(eventName, payload => + { + _items.Enqueue(payload); + _signal.Release(); + }); + } + + public async Task WaitForFirstAsync(Func predicate, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + if (TryFindMatch(predicate, out var prearrived)) + { + return prearrived; + } + try + { + while (await _signal.WaitAsync(timeout, cts.Token).ConfigureAwait(false)) + { + if (TryFindMatch(predicate, out var hit)) return hit; + } + } + catch (OperationCanceledException) { /* timeout — return default */ } + return default; + } + + private bool TryFindMatch(Func predicate, out T match) + { + var snapshot = new List(); + while (_items.TryDequeue(out var item)) snapshot.Add(item); + int idx = snapshot.FindIndex(p => predicate(p)); + if (idx < 0) + { + foreach (var item in snapshot) _items.Enqueue(item); + match = default!; + return false; + } + match = snapshot[idx]; + for (int i = 0; i < snapshot.Count; i++) + { + if (i != idx) _items.Enqueue(snapshot[i]); + } + return true; + } + + public void Dispose() + { + _subscription.Dispose(); + _signal.Dispose(); + } + } +} From 0d0d8e67cf8a344641368b2c9bfa76a335d5aa98 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 03:56:30 +0530 Subject: [PATCH 03/15] =?UTF-8?q?feat(admin):=20phase=201=20=E2=80=94=20ad?= =?UTF-8?q?opt=20dashboard=20design=20tokens=20&=20global=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace admin's "Console" token system with the dashboard's (palette, surfaces, shadows, radii, motion, base atmosphere/paper-grain) and switch fonts to Figtree / Outfit / JetBrains Mono. A temporary LEGACY ADMIN SHIM keeps the old console tokens (--accent-signal, --shadow-card*, --grid-alpha, …) and utility classes (.meta, .section-rule, .card-shell, .code-chip, .canvas-grid/mesh, .caret, .mono-tone*, …) resolving against the new palette so unmigrated pages don't break; the shim is deleted as Phases 2–4 migrate its consumers. Part of the admin → dashboard design unification (spec + PR #1268). Build: admin `npm run build` ✓. Co-Authored-By: Claude Opus 4.7 (1M context) --- clients/admin/index.html | 2 +- clients/admin/src/styles/globals.css | 1579 ++++++++++++++++++-------- 2 files changed, 1129 insertions(+), 452 deletions(-) diff --git a/clients/admin/index.html b/clients/admin/index.html index dd72e62969..f3ece6e87f 100644 --- a/clients/admin/index.html +++ b/clients/admin/index.html @@ -8,7 +8,7 @@ FullStackHero — Admin diff --git a/clients/admin/src/styles/globals.css b/clients/admin/src/styles/globals.css index 3813fac897..35ea1bfced 100644 --- a/clients/admin/src/styles/globals.css +++ b/clients/admin/src/styles/globals.css @@ -1,75 +1,78 @@ @import "tailwindcss"; -/* Typeface system — modern duo on a single family: - · Geist — display + body sans. Sharp grotesque made for screens. - · Geist Mono — every fact: IDs, timestamps, status, table data, crumbs. - One family across display + body + code so weights cascade consistently. - Both free from Google Fonts. */ -@import url("https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap"); - @custom-variant dark (&:is(.dark *)); /* ============================================================================ - "Console" — Editorial-terminal design tokens + FullStackHero Dashboard — Design Tokens ---------------------------------------------------------------------------- - The admin app sits at the intersection of a Bloomberg terminal and an - editorial magazine: dense, factual, opinionated, beautifully typeset. The - palette is monochrome with a single chartreuse-lime signal accent. Mono - is load-bearing — anywhere you see mono, you are looking at data. - - Architecture (top-down): - 1. PRIMITIVES — raw oklch scales. Never reference in components. - 2. SEMANTICS — purpose-named tokens (--background, --primary, …). - 3. SURFACES — explicit elevation tiers for layout chrome. - 4. SYSTEM — typography, motion, radii, shadows. + Architecture (read top-down): + 1. PRIMITIVES — raw oklch scales. Never reference these in components. + 2. SEMANTICS — purpose-named vars (--background, --primary, …). The + public API. Components reference these. + 3. SURFACES — explicit elevation steps for layout chrome. + 4. SYSTEM — typography, motion, radii, shadows. + Theme contract: shadcn-compatible. The classic --color-* names all exist + so existing primitives (Button, Card, Input, Label) keep working. ========================================================================== */ :root { /* ── 1. PRIMITIVES ──────────────────────────────────────────────────── */ - /* Neutrals — cool-cast (hue 240, near-zero chroma) for a quiet, - deliberately not-quite-grey chassis that gives the accent room. */ - --neutral-0: oklch(1.000 0.000 240); - --neutral-50: oklch(0.985 0.003 240); - --neutral-100: oklch(0.965 0.004 240); - --neutral-200: oklch(0.928 0.006 240); - --neutral-300: oklch(0.870 0.008 240); - --neutral-400: oklch(0.745 0.010 240); - --neutral-500: oklch(0.585 0.012 240); - --neutral-600: oklch(0.475 0.014 240); - --neutral-700: oklch(0.380 0.014 240); - --neutral-800: oklch(0.265 0.012 240); - --neutral-850: oklch(0.205 0.012 240); - --neutral-900: oklch(0.165 0.010 240); - --neutral-925: oklch(0.135 0.008 240); - --neutral-950: oklch(0.115 0.006 240); - --neutral-1000: oklch(0.085 0.005 240); - - /* Signal — chartreuse-lime. The only chromatic stop allowed in chrome. - One accent, deliberately recognizable. Reserved for primary affordances, - active nav, the "live" pulse, and the brand mark. Not for "this is OK" - — that's what `--success` is for. */ - --signal-300: oklch(0.940 0.140 125); - --signal-400: oklch(0.900 0.180 125); - --signal-500: oklch(0.860 0.200 125); - --signal-600: oklch(0.770 0.205 125); - --signal-700: oklch(0.660 0.180 125); - - /* Status scales — emerald / amber / cyan / rose. Only show up on - status badges and status pills; never used for chrome. */ - --success-500: oklch(0.690 0.160 162); - --success-600: oklch(0.605 0.160 162); - --warning-500: oklch(0.790 0.150 76); - --warning-600: oklch(0.700 0.150 76); - --info-500: oklch(0.720 0.130 215); - --info-600: oklch(0.635 0.135 215); - --danger-500: oklch(0.680 0.205 22); - --danger-600: oklch(0.585 0.215 22); - - /* ── 2. SEMANTICS — light theme is the *secondary* surface. Dark is - primary and the file's defaults follow that mental model below. - But we write light first so users who land here in light see a - fully-considered scheme too. ───────────────────────────────────── */ + /* Neutrals — true greys (chroma 0). The earlier "warm paper" chassis + tinted every surface yellow-cream, which fought with non-rose + accent palettes and read as a global yellow cast in dark mode. + Neutrals are now untinted so the selected accent (rose, indigo, + violet, sky, emerald, amber) is the only colour in the room. */ + --neutral-0: oklch(1.000 0 0); + --neutral-50: oklch(0.985 0 0); + --neutral-100: oklch(0.960 0 0); + --neutral-200: oklch(0.920 0 0); + --neutral-300: oklch(0.870 0 0); + --neutral-400: oklch(0.735 0 0); + --neutral-500: oklch(0.575 0 0); + --neutral-600: oklch(0.460 0 0); + --neutral-700: oklch(0.360 0 0); + --neutral-800: oklch(0.245 0 0); + --neutral-850: oklch(0.200 0 0); + --neutral-900: oklch(0.155 0 0); + --neutral-950: oklch(0.110 0 0); + --neutral-1000: oklch(0.075 0 0); + + /* Brand — confident rose (#f91942 at the 600 stop). 600 is the + workhorse; 500 is for dark mode. Override these eleven stops via + an `accent-{id}` class on :root (defined further down) to swap the + entire brand palette. */ + --brand-50: oklch(0.972 0.022 13); + --brand-100: oklch(0.945 0.045 13); + --brand-200: oklch(0.895 0.090 13); + --brand-300: oklch(0.825 0.145 13); + --brand-400: oklch(0.720 0.195 13); + --brand-500: oklch(0.640 0.225 13); + --brand-600: oklch(0.575 0.232 13); + --brand-700: oklch(0.495 0.215 13); + --brand-800: oklch(0.410 0.180 13); + --brand-900: oklch(0.330 0.140 13); + --brand-950: oklch(0.235 0.095 13); + + /* Saffron — secondary warm accent. Used sparingly for the second + channel of warmth against rose (gradient endpoints, hero numerals, + trust marks). Mirrors dentalOS #e8a54b. */ + --saffron-400: oklch(0.815 0.115 72); + --saffron-500: oklch(0.760 0.137 72); + --saffron-600: oklch(0.680 0.135 68); + + /* Status scales — emerald, amber, sky, rose. All at L≈0.6/0.7 so they + read as a coherent set when stacked next to each other. */ + --success-500: oklch(0.660 0.155 162); + --success-600: oklch(0.575 0.155 162); + --warning-500: oklch(0.770 0.155 76); + --warning-600: oklch(0.685 0.155 76); + --info-500: oklch(0.685 0.150 232); + --info-600: oklch(0.605 0.150 232); + --danger-500: oklch(0.625 0.220 20); + --danger-600: oklch(0.555 0.220 20); + + /* ── 2. SEMANTICS (light) ──────────────────────────────────────────── */ --background: var(--neutral-50); --foreground: var(--neutral-950); @@ -78,243 +81,379 @@ --popover: var(--neutral-0); --popover-foreground: var(--neutral-950); - --primary: var(--neutral-950); - --primary-foreground: var(--neutral-50); - --primary-soft: oklch(0.115 0.006 240 / 0.08); + --primary: var(--brand-600); + /* Derive from the active brand stop so accent swaps re-tone these + too. The `from ` relative-color syntax resolves at runtime. */ + --primary-foreground: oklch(from var(--brand-600) 0.990 0.005 h); + --primary-hover: var(--brand-700); + --primary-soft: oklch(from var(--brand-600) l c h / 0.10); - /* Signal/accent — same in both modes (chartreuse needs no inversion). */ - --accent-signal: var(--signal-500); - --accent-signal-soft: oklch(0.860 0.200 125 / 0.14); - --accent-signal-foreground: var(--neutral-1000); + /* Theme-independent. For content that sits on top of imagery or a dark + scrim (caption text, overlay action icons) and for the thumb of a + filled control — white stays legible in both light and dark, so these + are intentionally NOT flipped in the .dark block. */ + --overlay: oklch(0 0 0 / 0.55); + --overlay-foreground: oklch(1 0 0); --secondary: var(--neutral-100); --secondary-foreground: var(--neutral-900); --muted: var(--neutral-100); - --muted-foreground: var(--neutral-500); - - --accent: var(--neutral-100); - --accent-foreground: var(--neutral-900); + /* Bumped from neutral-500 (L=0.575) to L=0.500 so that all muted body + copy and micro-labels clear WCAG 2.2 AA 4.5:1 contrast on every + surface, including the alpha-mask variants used for placeholders + and chevrons. */ + --muted-foreground: oklch(0.500 0 0); + + /* Accent = neutral hover chip. Was previously a warm cream tint that + contributed to the global yellow cast; same L as before but with + zero chroma, so it still reads as a distinct hover chip a notch + darker than --muted / --secondary. */ + --accent: oklch(0.940 0 0); + --accent-foreground: var(--neutral-800); + --saffron: var(--saffron-500); + --saffron-foreground: oklch(0.250 0.060 68); --destructive: var(--danger-600); - --destructive-foreground: oklch(0.990 0.005 22); + --destructive-foreground: oklch(0.990 0.005 20); --success: var(--success-600); --success-foreground: oklch(0.990 0.005 162); --warning: var(--warning-600); --warning-foreground: var(--neutral-950); --info: var(--info-600); - --info-foreground: oklch(0.990 0.005 215); + --info-foreground: oklch(0.990 0.005 232); - --border: oklch(0.870 0.008 240 / 0.55); - --border-strong: oklch(0.745 0.010 240); - --border-emphasis: var(--neutral-900); + --border: oklch(0.895 0 0 / 0.85); + --border-strong: oklch(0.840 0 0); --input: var(--neutral-200); - --ring: var(--signal-500); - - /* ── 3. SURFACES — elevation tiers. ─────────────────────────────────── */ - --surface-1: var(--neutral-50); /* page canvas */ - --surface-2: var(--neutral-0); /* sidebar, panels, code chips */ - --surface-3: var(--neutral-0); /* cards */ + --ring: var(--brand-500); + + /* ── 3. SURFACES — explicit elevation. Use these instead of mixing + card/muted/accent ad-hoc. + Light mode: canvas → tier-2 panel → tier-3 popover/sticky → tier-4 hover. + Each tier is one perceptual step distinct so cards visually float + above the canvas instead of merging into a hairline-edged plane. */ + --surface-1: var(--neutral-50); /* canvas */ + --surface-2: var(--neutral-0); /* sidebar, panels */ + --surface-3: oklch(0.992 0 0); /* cards, popovers, sticky chrome */ --surface-4: var(--neutral-100); /* hover, raised */ - --surface-overlay: oklch(0.115 0.006 240 / 0.35); + --surface-1-hover: var(--neutral-100); + --surface-2-hover: var(--neutral-50); + + /* ── Charts — laddered around the rose brand axis. Saffron is the + primary counter-hue (warm pair), with teal/emerald/violet rounding + out the harmonic ladder. ────────────────────────────────────────── */ + --chart-1: var(--brand-500); /* rose */ + --chart-2: var(--saffron-500); /* saffron */ + --chart-3: oklch(0.700 0.135 195); /* teal */ + --chart-4: oklch(0.660 0.180 320); /* violet */ + --chart-5: oklch(0.665 0.155 162); /* emerald */ + --chart-grid: oklch(0.895 0 0 / 0.6); + + /* ── 4. SYSTEM ─────────────────────────────────────────────────────── */ + /* Display = Outfit (rounded geometric, headings + hero numerals). + Body = Figtree (warm open sans-serif, everything else). + Mono = JetBrains Mono (terminal/console flourishes, tabular data). */ + --font-display: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-sans: "Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + /* Body uses tabular numerals so times, tokens, and costs don't jitter + between digits. Display reads with default proportional numerals + except where overridden (.text-display, mono numerics in lists). */ + --font-feature-default: "tnum"; + --font-feature-tabular: "tnum"; + + --radius: 0.625rem; + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); - /* Grid texture alpha — 4px hairline subgrid on the canvas. Light-mode - subtle; dark-mode slightly more visible since the near-black canvas - needs more contrast to read the texture. */ - --grid-alpha: 0.04; + --ease-out-cubic: cubic-bezier(0.215, 0.610, 0.355, 1.000); + --ease-out-quart: cubic-bezier(0.165, 0.840, 0.440, 1.000); + --ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1.000); + --duration-fast: 120ms; + --duration-default: 200ms; + --duration-slow: 320ms; + + /* Neutral shadows — cast in pure black so they read as a cool + shadow on any chassis (no warm tint bleeding into the canvas). */ + --shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.05); + --shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.06), + 0 1px 3px 0 oklch(0 0 0 / 0.07); + --shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.08), + 0 2px 4px -2px oklch(0 0 0 / 0.06); + --shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.10), + 0 4px 6px -4px oklch(0 0 0 / 0.06); + --shadow-lift: 0 12px 32px -16px oklch(0 0 0 / 0.22), + 0 4px 8px -4px oklch(0 0 0 / 0.12); + + /* Top-edge highlight — kept very subtle. Earlier value (0.55 alpha) + read as 2014-era glassmorphism gloss; toned to 0.14 so it + whispers rather than shouts. Only present on surfaces that + explicitly opt in via `surface-edge`, `gradient-border`, etc. */ + --highlight-top: inset 0 1px 0 oklch(1 0 0 / 0.14); + + /* Display-weight tracking ratio applied to hero headings and page + titles. Tighter than body, slightly heavier optical weight. */ + --tracking-display: -0.025em; + --tracking-tight: -0.015em; --color-scheme: light; } +/* ───────────────────────────────────────────────────────────────────────── + ACCENT PALETTES — each class overrides the 11 brand stops with a + different hue, keeping the L/C ramp identical. Apply by toggling a + single class on :root (e.g. `accent-violet`). + Hue chosen per accent: + rose (default) → 13 · indigo → 268 · violet → 305 · sky → 232 + emerald → 152 · amber → 76 + Rose is the default in :root above so no `.accent-rose` is needed + unless an upstream selection persists. Indigo is an opt-in for + teams that prefer the cool Linear chassis. + ─────────────────────────────────────────────────────────────────────── */ +.accent-rose { + --brand-50: oklch(0.972 0.022 13); + --brand-100: oklch(0.945 0.045 13); + --brand-200: oklch(0.895 0.090 13); + --brand-300: oklch(0.825 0.145 13); + --brand-400: oklch(0.720 0.195 13); + --brand-500: oklch(0.640 0.225 13); + --brand-600: oklch(0.575 0.232 13); + --brand-700: oklch(0.495 0.215 13); + --brand-800: oklch(0.410 0.180 13); + --brand-900: oklch(0.330 0.140 13); + --brand-950: oklch(0.235 0.095 13); +} +.accent-indigo { + --brand-50: oklch(0.972 0.020 268); + --brand-100: oklch(0.945 0.040 268); + --brand-200: oklch(0.895 0.078 268); + --brand-300: oklch(0.825 0.130 268); + --brand-400: oklch(0.720 0.180 268); + --brand-500: oklch(0.620 0.210 268); + --brand-600: oklch(0.555 0.220 268); + --brand-700: oklch(0.485 0.205 268); + --brand-800: oklch(0.405 0.175 268); + --brand-900: oklch(0.325 0.135 268); + --brand-950: oklch(0.230 0.090 268); +} +.accent-violet { + --brand-50: oklch(0.972 0.020 305); + --brand-100: oklch(0.945 0.040 305); + --brand-200: oklch(0.895 0.078 305); + --brand-300: oklch(0.825 0.130 305); + --brand-400: oklch(0.720 0.180 305); + --brand-500: oklch(0.620 0.210 305); + --brand-600: oklch(0.555 0.220 305); + --brand-700: oklch(0.485 0.205 305); + --brand-800: oklch(0.405 0.175 305); + --brand-900: oklch(0.325 0.135 305); + --brand-950: oklch(0.230 0.090 305); +} +.accent-sky { + --brand-50: oklch(0.972 0.020 232); + --brand-100: oklch(0.945 0.040 232); + --brand-200: oklch(0.895 0.078 232); + --brand-300: oklch(0.825 0.130 232); + --brand-400: oklch(0.720 0.170 232); + --brand-500: oklch(0.620 0.180 232); + --brand-600: oklch(0.555 0.180 232); + --brand-700: oklch(0.485 0.170 232); + --brand-800: oklch(0.405 0.150 232); + --brand-900: oklch(0.325 0.115 232); + --brand-950: oklch(0.230 0.078 232); +} +.accent-emerald { + --brand-50: oklch(0.972 0.020 152); + --brand-100: oklch(0.945 0.040 152); + --brand-200: oklch(0.895 0.078 152); + --brand-300: oklch(0.825 0.120 152); + --brand-400: oklch(0.720 0.155 152); + --brand-500: oklch(0.620 0.170 152); + --brand-600: oklch(0.545 0.170 152); + --brand-700: oklch(0.470 0.155 152); + --brand-800: oklch(0.395 0.135 152); + --brand-900: oklch(0.320 0.110 152); + --brand-950: oklch(0.225 0.075 152); +} +.accent-amber { + --brand-50: oklch(0.972 0.025 76); + --brand-100: oklch(0.945 0.050 76); + --brand-200: oklch(0.895 0.090 76); + --brand-300: oklch(0.840 0.130 76); + --brand-400: oklch(0.770 0.155 76); + --brand-500: oklch(0.700 0.170 76); + --brand-600: oklch(0.620 0.165 76); + --brand-700: oklch(0.530 0.150 76); + --brand-800: oklch(0.440 0.130 76); + --brand-900: oklch(0.355 0.105 76); + --brand-950: oklch(0.255 0.075 76); +} +/* ───────────────────────────────────────────────────────────────────────── + DARK MODE — values flip; semantic names stay. + ─────────────────────────────────────────────────────────────────────── */ .dark { + /* Deep graphite, untinted. Earlier value carried a warm undertone + (h≈66) that read as a yellow cast across every dark surface; + neutralised so the chosen accent is the only hue on screen. */ --background: var(--neutral-1000); - --foreground: var(--neutral-50); + --foreground: oklch(0.925 0 0); - --card: var(--neutral-925); - --card-foreground: var(--neutral-50); - --popover: var(--neutral-900); - --popover-foreground: var(--neutral-50); + --card: var(--neutral-900); + --card-foreground: oklch(0.925 0 0); + --popover: var(--neutral-850); + --popover-foreground: oklch(0.925 0 0); - --primary: var(--neutral-50); - --primary-foreground: var(--neutral-1000); - --primary-soft: oklch(0.985 0.003 240 / 0.10); + --primary: var(--brand-500); + --primary-foreground: oklch(from var(--brand-500) 0.990 0.005 h); + --primary-hover: var(--brand-400); + --primary-soft: oklch(from var(--brand-500) l c h / 0.18); - --accent-signal: var(--signal-500); - --accent-signal-soft: oklch(0.860 0.200 125 / 0.18); - --accent-signal-foreground: var(--neutral-1000); + --secondary: var(--neutral-850); + --secondary-foreground: oklch(0.925 0 0); - --secondary: var(--neutral-800); - --secondary-foreground: var(--neutral-50); + --muted: var(--neutral-850); + /* Bumped from L=0.680 to L=0.730 so muted body copy clears 4.5:1 + against the graphite background even when consumers apply an + /0.6 alpha mask (placeholders, chevrons, micro-meta). */ + --muted-foreground: oklch(0.730 0 0); - --muted: var(--neutral-900); - --muted-foreground: var(--neutral-400); - - --accent: var(--neutral-900); - --accent-foreground: var(--neutral-50); + --accent: var(--neutral-800); + --accent-foreground: oklch(0.925 0 0); + --saffron: var(--saffron-500); + --saffron-foreground: oklch(0.115 0.025 62); --destructive: var(--danger-500); - --destructive-foreground: oklch(0.145 0.040 22); + --destructive-foreground: oklch(0.990 0.005 20); --success: var(--success-500); - --success-foreground: oklch(0.145 0.040 162); --warning: var(--warning-500); - --warning-foreground: var(--neutral-1000); --info: var(--info-500); - --info-foreground: oklch(0.145 0.040 215); - --border: oklch(0.265 0.012 240 / 0.75); - --border-strong: oklch(0.345 0.012 240); - --border-emphasis: var(--neutral-50); + --border: oklch(0.260 0 0 / 0.85); + --border-strong: oklch(0.380 0 0); --input: var(--neutral-800); - --ring: var(--signal-500); + --ring: var(--brand-400); --surface-1: var(--neutral-1000); - --surface-2: var(--neutral-925); - --surface-3: var(--neutral-900); - --surface-4: var(--neutral-850); - --surface-overlay: oklch(0.085 0.005 240 / 0.60); - - --grid-alpha: 0.045; + --surface-2: var(--neutral-900); + --surface-3: var(--neutral-850); + --surface-4: var(--neutral-800); + --surface-1-hover: var(--neutral-950); + --surface-2-hover: var(--neutral-850); + + --chart-1: var(--brand-400); + --chart-2: var(--saffron-400); + --chart-3: oklch(0.745 0.135 195); + --chart-4: oklch(0.705 0.180 320); + --chart-5: oklch(0.710 0.155 162); + --chart-grid: oklch(0.260 0 0 / 0.6); + + --highlight-top: inset 0 1px 0 oklch(1 0 0 / 0.06); + + --shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.20), + 0 1px 3px 0 oklch(0 0 0 / 0.24); + --shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.28), + 0 2px 4px -2px oklch(0 0 0 / 0.20); + --shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.34), + 0 4px 6px -4px oklch(0 0 0 / 0.22); + --shadow-lift: 0 16px 36px -18px oklch(0 0 0 / 0.55), + 0 6px 12px -6px oklch(0 0 0 / 0.35); --color-scheme: dark; } +/* ───────────────────────────────────────────────────────────────────────── + THEME BLOCK — exposes semantics as Tailwind v4 utilities. + `bg-[var(--color-X)]` and `bg-{X}` both work. + ─────────────────────────────────────────────────────────────────────── */ @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-primary-soft: var(--primary-soft); - --color-accent-signal: var(--accent-signal); - --color-accent-signal-soft: var(--accent-signal-soft); - --color-accent-signal-foreground: var(--accent-signal-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); - --color-info: var(--info); - --color-info-foreground: var(--info-foreground); - --color-border: var(--border); - --color-border-strong: var(--border-strong); - --color-border-emphasis: var(--border-emphasis); - --color-input: var(--input); - --color-ring: var(--ring); - --color-surface-1: var(--surface-1); - --color-surface-2: var(--surface-2); - --color-surface-3: var(--surface-3); - --color-surface-4: var(--surface-4); - - --radius: 0.5rem; /* 8 — buttons */ - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: calc(var(--radius) + 2px); /* 10 */ - --radius-xl: 1rem; /* 16 — cards */ - --radius-2xl: 1.25rem; /* 20 — sheets / hero cards */ - - /* Card elevation — multi-stop, intentionally restrained. The hover - state lifts slightly and adds a chartreuse-tinted outer ring at - extremely low alpha; depth from elevation, not chrome. */ - --shadow-card: - 0 1px 2px 0 oklch(0 0 0 / 0.04), - 0 1px 3px -1px oklch(0 0 0 / 0.05); - --shadow-card-hover: - 0 1px 2px 0 oklch(0 0 0 / 0.04), - 0 8px 16px -8px oklch(0 0 0 / 0.10), - 0 14px 32px -16px oklch(0 0 0 / 0.16); - --shadow-card-hover-dark: - 0 1px 2px 0 oklch(0 0 0 / 0.40), - 0 8px 16px -8px oklch(0 0 0 / 0.45), - 0 18px 40px -16px oklch(0 0 0 / 0.55); - - /* Top-edge highlight — the subtle "lit from above" line that gives - elevated surfaces depth without a heavy border. */ - --highlight-top-light: inset 0 1px 0 oklch(1 0 0 / 0.55); - --highlight-top-dark: inset 0 1px 0 oklch(1 0 0 / 0.04); - - --font-display: "Geist", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-sans: "Geist", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; - - --ease-out-cubic: cubic-bezier(0.215, 0.610, 0.355, 1.000); - --ease-out-quart: cubic-bezier(0.165, 0.840, 0.440, 1.000); - --ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1.000); - --duration-fast: 140ms; - --duration-default: 240ms; - --duration-slow: 360ms; - - --tracking-display: -0.028em; - --tracking-tight: -0.012em; - --tracking-meta: 0.22em; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-primary-hover: var(--primary-hover); + --color-primary-soft: var(--primary-soft); + --color-overlay: var(--overlay); + --color-overlay-foreground: var(--overlay-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-saffron: var(--saffron); + --color-saffron-foreground: var(--saffron-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-surface-1: var(--surface-1); + --color-surface-2: var(--surface-2); + --color-surface-3: var(--surface-3); + --color-surface-4: var(--surface-4); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --radius-sm: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: var(--radius-xl); + --radius-2xl: var(--radius-2xl); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-display: var(--font-display); + + /* Type scale — canonical display sizes consumed by page primitives. + Page = top-of-page hero (list & detail). + Section = nested section header inside a page (role-detail, etc.). + Card = card-level title. + Stat = big numerals on stat cards / billing widgets. + Tailwind v4 auto-generates `text-display-page` etc. utilities from + these tokens. */ + --text-display-page: 1.5rem; /* 24px */ + --text-display-section: 1.25rem; /* 20px */ + --text-display-card: 1.0625rem; /* 17px */ + --text-display-stat: 1.75rem; /* 28px */ } -/* ============================================================================ - Base layer - ========================================================================== */ - +/* ───────────────────────────────────────────────────────────────────────── + BASE LAYER — global treatments + ─────────────────────────────────────────────────────────────────────── */ @layer base { - *, - *::before, - *::after { + *, ::before, ::after { border-color: var(--color-border); } - html { + :root { color-scheme: var(--color-scheme); - background-color: var(--color-background); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - } - - body { - font-family: var(--font-sans); - background-color: var(--color-background); - color: var(--color-foreground); - font-feature-settings: "ss01", "cv11"; - letter-spacing: var(--tracking-tight); - } - - /* Selection reads as "selected by the system" — chartreuse signal. */ - ::selection { - background-color: oklch(from var(--color-accent-signal) l c h / 0.35); - color: var(--color-foreground); - } - - /* Scrollbar — thin, monochrome, never indigo. */ - * { - scrollbar-width: thin; - scrollbar-color: var(--color-border-strong) transparent; - } - *::-webkit-scrollbar { width: 9px; height: 9px; } - *::-webkit-scrollbar-track { background: transparent; } - *::-webkit-scrollbar-thumb { - background-color: oklch(from var(--color-border-strong) l c h / 0.65); - border-radius: 8px; - border: 2px solid transparent; - background-clip: content-box; - } - - h1, h2, h3 { - font-family: var(--font-display); - letter-spacing: var(--tracking-display); } /* Cursor affordance — restore the classic "anything clickable shows a pointer" behavior. Tailwind v4 follows the new browser default of - `cursor: default` on + {hasImage && !isWorking && ( + + )} + {isUploading && progress && ( + + {progress.percent}% · {formatBytes(progress.loaded)} / {formatBytes(progress.totalBytes)} + + )} + + ) : ( + onChange(e.target.value)} + placeholder="https://…" + maxLength={512} + /> + )} + +

+ {mode === "upload" + ? `JPG/PNG/WebP/GIF · up to ${formatBytes(maxBytes)}` + : "Direct link to an image you host elsewhere."} +

+ + + + ); +} + +function ModeChip({ + active, + onClick, + icon, + children, +}: { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/clients/admin/src/components/list/entity-page-header.tsx b/clients/admin/src/components/list/entity-page-header.tsx new file mode 100644 index 0000000000..a6aecee23f --- /dev/null +++ b/clients/admin/src/components/list/entity-page-header.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { ToneIconTile, type ToneIconTileTone } from "./tone-icon-tile"; + +// ─────────────────────────────────────────────────────────────────────── +// EntityPageHeader — tone-tinted icon tile + Outfit title + count chip +// + description on the left, action buttons on the right. +// Replaces the dashboard-divergent FormShell page-title area with the +// unified header rhythm used across the dashboard app. +// ─────────────────────────────────────────────────────────────────────── + +export function EntityPageHeader({ + icon, + title, + tone = "primary", + total, + unit = "item", + description, + children, +}: { + icon: LucideIcon; + title: React.ReactNode; + /** Icon tile tone. Defaults to `primary`. + * Pick `saffron` / `info` / etc. for pages where the rose tile + * fights the page's own accent. */ + tone?: ToneIconTileTone; + total?: number | null; + unit?: string; + description?: React.ReactNode; + /** Action buttons rendered on the right (stack full-width on mobile). */ + children?: React.ReactNode; +}) { + return ( +
+
+ +
+
+

+ {title} +

+ {total !== undefined && total !== null && ( + + {total} {total === 1 ? unit : `${unit}s`} + + )} +
+ {description && ( +

+ {description} +

+ )} +
+
+ + {children && ( +
{children}
+ )} +
+ ); +} diff --git a/clients/admin/src/components/list/index.ts b/clients/admin/src/components/list/index.ts index 7b281cea27..707fa5cdc2 100644 --- a/clients/admin/src/components/list/index.ts +++ b/clients/admin/src/components/list/index.ts @@ -7,3 +7,8 @@ export { Select, type SelectOption } from "./select"; export { LoadingRow } from "./loading"; export { FilterBar } from "./filter-bar"; export { FormShell, FormSection, FormActions } from "./form-shell"; +// ── Phase-2 unified design-system primitives ────────────────────────── +export { ToneIconTile, type ToneIconTileTone, type ToneIconTileSize } from "./tone-icon-tile"; +export { EntityPageHeader } from "./entity-page-header"; +export { SettingsSection } from "./settings-section"; +export { SettingsField } from "./settings-field"; diff --git a/clients/admin/src/components/list/settings-field.tsx b/clients/admin/src/components/list/settings-field.tsx new file mode 100644 index 0000000000..cd68375284 --- /dev/null +++ b/clients/admin/src/components/list/settings-field.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { Label } from "@/components/ui/label"; + +// ─────────────────────────────────────────────────────────────────────── +// SettingsField — lightweight label wrapper used inside SettingsSection +// body grids. Renders an uppercase tracked label above its child control. +// Use the existing `Field` component when you need aria-invalid wiring, +// error messages, or hint text; use SettingsField when the section +// already supplies context and you just need the label rhythm. +// +// Usage: +// +// +// +// ─────────────────────────────────────────────────────────────────────── + +export function SettingsField({ + id, + label, + children, +}: { + id: string; + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/clients/admin/src/components/list/settings-section.tsx b/clients/admin/src/components/list/settings-section.tsx new file mode 100644 index 0000000000..b1dee5c506 --- /dev/null +++ b/clients/admin/src/components/list/settings-section.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; + +// ─────────────────────────────────────────────────────────────────────── +// SettingsSection — compact section card with header bar, body, and +// optional footer bar. Replaces the 18rem-aside FormSection layout with +// a dashboard-style card: icon + title + description in a header stripe, +// content in a padded body, optional action row in a footer stripe. +// +// Usage: +// Save}> +// {children} +// +// ─────────────────────────────────────────────────────────────────────── + +export function SettingsSection({ + title, + icon: Icon, + description, + footer, + className, + children, +}: { + title?: string; + icon?: LucideIcon; + description?: React.ReactNode; + footer?: React.ReactNode; + className?: string; + children: React.ReactNode; +}) { + return ( +
+ {title && ( +
+

+ {Icon && ( + + )} + {title} +

+ {description && ( +

+ {description} +

+ )} +
+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+ ); +} diff --git a/clients/admin/src/components/list/tone-icon-tile.tsx b/clients/admin/src/components/list/tone-icon-tile.tsx new file mode 100644 index 0000000000..509bcb0b2d --- /dev/null +++ b/clients/admin/src/components/list/tone-icon-tile.tsx @@ -0,0 +1,75 @@ +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; + +// ─────────────────────────────────────────────────────────────────────── +// ToneIconTile — tone-tinted icon square. +// A rounded tile with a 10%-alpha background fill, a 22%-alpha inset +// ring, and an icon in the tone's full colour. +// +// Usage: +// +// +// All seven semantic tones are supported. The default (`primary` / `lg`) +// matches `EntityPageHeader`'s tile so the page header stays visually +// identical when refactored to use this primitive directly. +// ─────────────────────────────────────────────────────────────────────── + +export type ToneIconTileTone = + | "primary" + | "saffron" + | "success" + | "warning" + | "destructive" + | "info" + | "muted"; + +export type ToneIconTileSize = "sm" | "md" | "lg"; + +const TONE_VAR: Record = { + primary: "--color-primary", + saffron: "--color-saffron", + success: "--color-success", + warning: "--color-warning", + destructive: "--color-destructive", + info: "--color-info", + muted: "--color-muted-foreground", +}; + +const SIZE_MAP: Record = { + sm: { tile: "size-7", icon: "size-3.5", radius: "rounded-md" }, + md: { tile: "size-9", icon: "size-4", radius: "rounded-lg" }, + lg: { tile: "size-10", icon: "size-5", radius: "rounded-xl" }, +}; + +export function ToneIconTile({ + icon: Icon, + tone = "primary", + size = "lg", + className, +}: { + icon: LucideIcon; + tone?: ToneIconTileTone; + size?: ToneIconTileSize; + className?: string; +}) { + const dims = SIZE_MAP[size]; + const v = TONE_VAR[tone]; + return ( + + + + ); +} diff --git a/clients/admin/src/components/ui/avatar.tsx b/clients/admin/src/components/ui/avatar.tsx new file mode 100644 index 0000000000..5ef000c645 --- /dev/null +++ b/clients/admin/src/components/ui/avatar.tsx @@ -0,0 +1,129 @@ +import * as React from "react"; +import { cn } from "@/lib/cn"; + +type AvatarSize = "xs" | "sm" | "md" | "lg"; + +type AvatarProps = React.HTMLAttributes & { + /** Display name; up to two leading characters become the fallback initials. */ + name?: string | null; + /** Optional image URL. Falls back to the initials when missing or fails to load. */ + src?: string | null; + size?: AvatarSize; + /** When true, emits a small pulsing status dot at the bottom-right. */ + status?: "online" | "offline" | "warning"; + /** Adds a soft brand halo around the avatar (used in the dropdown hero). */ + halo?: boolean; +}; + +const sizeClass: Record = { + xs: "h-5 w-5 text-[10px]", + sm: "h-7 w-7 text-[11px]", + md: "h-9 w-9 text-[13px]", + lg: "h-12 w-12 text-[16px]", +}; + +// Pixel dims mirror `sizeClass`. Setting width + height on the +// prevents avatar swap-in from triggering CLS while the image loads. +const sizePx: Record = { + xs: 20, + sm: 28, + md: 36, + lg: 48, +}; + +const dotSize: Record = { + xs: "h-1 w-1", + sm: "h-1.5 w-1.5", + md: "h-2 w-2", + lg: "h-2.5 w-2.5", +}; + +function getInitials(name?: string | null): string { + if (!name) return "?"; + const trimmed = name.trim(); + if (trimmed.length === 0) return "?"; + const parts = trimmed.split(/\s+/).slice(0, 2); + return parts.map((p) => p.charAt(0).toUpperCase()).join(""); +} + +function statusColor(status: AvatarProps["status"]): string | undefined { + switch (status) { + case "online": + return "var(--color-success)"; + case "warning": + return "var(--color-warning)"; + case "offline": + return "var(--color-muted-foreground)"; + default: + return undefined; + } +} + +/** + * Avatar — circular surface that carries the brand-mark vocabulary. + * The base is the rotating conic gradient under a top-edge highlight; + * the user's initial sits on top in primary-foreground. When `src` is + * provided, the image covers the gradient. An optional status dot + * anchors at the bottom-right and pulses for `online`. + */ +export const Avatar = React.forwardRef( + ({ name, src, size = "md", status, halo, className, ...props }, ref) => { + const initials = getInitials(name); + const dotColor = statusColor(status); + const [imgFailed, setImgFailed] = React.useState(false); + const showImage = Boolean(src) && !imgFailed; + + return ( + + + {showImage ? ( + {name setImgFailed(true)} + className="h-full w-full object-cover" + /> + ) : ( + {initials} + )} + + {dotColor && ( + + )} + + ); + }, +); +Avatar.displayName = "Avatar"; diff --git a/clients/admin/src/components/ui/badge.tsx b/clients/admin/src/components/ui/badge.tsx index bf8584bcb3..657622bd74 100644 --- a/clients/admin/src/components/ui/badge.tsx +++ b/clients/admin/src/components/ui/badge.tsx @@ -3,10 +3,12 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/cn"; /** - * Badge — compact status pill. Variants map to semantic status tokens; a - * brand re-tone propagates without touching call sites. Status backgrounds - * use the `oklch(from … / α)` relative-color syntax so the tint always - * matches the active foreground hue. + * Badge — compact status pill. Variants map to semantic tokens so a + * brand re-tone propagates without touching call sites. The `soft` + * style uses the matching `*-soft` background where defined and falls + * back to a tinted layer otherwise. + * + * Admin-specific variant `muted` is preserved for call-site compat. */ const badgeVariants = cva( "inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium tracking-tight whitespace-nowrap", @@ -14,11 +16,12 @@ const badgeVariants = cva( variants: { variant: { default: - "border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-foreground)]", + "border-[var(--color-border)] bg-[var(--color-card)] text-[var(--color-foreground)]", + brand: + "border-transparent bg-[var(--color-primary-soft)] text-[var(--color-primary)]", + // `muted` kept for admin call-site compat. muted: "border-transparent bg-[var(--color-muted)] text-[var(--color-muted-foreground)]", - brand: - "border-transparent bg-[var(--color-primary-soft)] text-[var(--color-foreground)]", success: "border-transparent bg-[oklch(from_var(--color-success)_l_c_h_/_0.14)] text-[var(--color-success)]", warning: diff --git a/clients/admin/src/components/ui/button.tsx b/clients/admin/src/components/ui/button.tsx index 08d80a6cbc..aafde77c93 100644 --- a/clients/admin/src/components/ui/button.tsx +++ b/clients/admin/src/components/ui/button.tsx @@ -4,51 +4,61 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/cn"; /** - * Button — Console primitives. + * Button — FSH unified design system. * - * Variants: - * • default — solid primary (near-black/near-white based on theme). - * • signal — chartreuse accent. The hero CTA, used SPARINGLY. - * Reserved for single primary actions per surface. - * • outline — hairline border, transparent fill. Default neutral action. - * • secondary — filled secondary surface. - * • ghost — text-only, hover surfaces the accent background. + * Variants (preserved + extended): + * • default — solid primary brand fill with hover lift shadow. + * • signal — maps to primary (was Console chartreuse; kept for admin call-site compat). * • destructive — danger tone. - * • link — inline text link. + * • outline — hairline border, transparent fill. + * • secondary — filled secondary surface. + * • ghost — text-only, hover surfaces the accent background. + * • link — inline text link with underline. + * • soft — primary-soft tinted background. + * • saffron — saffron accent (warm second channel). */ const buttonVariants = cva( [ - "inline-flex items-center justify-center gap-2 whitespace-nowrap", - "rounded-md text-sm font-medium", - "transition-[background-color,color,border-color,box-shadow,transform]", - "duration-[var(--duration-fast)] ease-[var(--ease-out-cubic)]", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]", + "inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap", + "rounded-md text-sm font-medium font-sans tracking-tight", + "transition-all duration-[var(--duration-fast)] ease-[var(--ease-out-cubic)]", + "outline-none focus-visible:ring-[3px] focus-visible:ring-[oklch(from_var(--color-ring)_l_c_h_/_0.5)] focus-visible:border-[var(--color-ring)]", "disabled:pointer-events-none disabled:opacity-50", - "active:scale-[0.985]", + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "active:scale-[0.98]", ].join(" "), { variants: { variant: { default: - "bg-[var(--color-primary)] text-[var(--color-primary-foreground)] hover:bg-[oklch(from_var(--color-primary)_calc(l_-_0.04)_c_h)]", + "bg-[var(--color-primary)] text-[var(--color-primary-foreground)] hover:brightness-[1.05] hover:shadow-[0_4px_18px_-6px_oklch(from_var(--color-primary)_l_c_h_/_0.45)]", + // `signal` kept for admin call-site compat — maps to primary. signal: - "bg-[var(--color-accent-signal)] text-[var(--color-accent-signal-foreground)] shadow-[0_0_0_1px_oklch(from_var(--color-accent-signal)_calc(l_-_0.10)_c_h)] hover:bg-[oklch(from_var(--color-accent-signal)_calc(l_-_0.04)_c_h)]", + "bg-[var(--color-primary)] text-[var(--color-primary-foreground)] hover:brightness-[1.05] hover:shadow-[0_4px_18px_-6px_oklch(from_var(--color-primary)_l_c_h_/_0.45)]", destructive: - "bg-[var(--color-destructive)] text-[var(--color-destructive-foreground)] hover:opacity-90", - outline: - "border border-[var(--color-border-strong)] bg-transparent text-[var(--color-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]", + "bg-[var(--color-destructive)] text-[var(--color-destructive-foreground)] hover:brightness-[1.05]", + outline: [ + "border border-[var(--color-input)] bg-[var(--color-card)] text-[var(--color-foreground)] shadow-xs", + "hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]", + ].join(" "), secondary: - "bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)] hover:bg-[var(--color-accent)]", + "bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)] hover:opacity-90", ghost: "text-[var(--color-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]", - link: - "text-[var(--color-foreground)] underline-offset-4 hover:underline decoration-[var(--color-accent-signal)] decoration-2", + link: "text-[var(--color-primary)] underline-offset-4 hover:underline", + soft: "bg-[var(--color-primary-soft)] text-[var(--color-primary)] hover:brightness-[1.06]", + saffron: + "bg-[var(--color-saffron)] text-[var(--color-saffron-foreground)] hover:brightness-[1.05] hover:shadow-[0_4px_18px_-6px_oklch(from_var(--color-saffron)_l_c_h_/_0.45)]", }, size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-5", - icon: "h-9 w-9", + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-7 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-7 rounded-md [&_svg:not([class*='size-'])]:size-3.5", + "icon-sm": "size-9", + "icon-lg": "size-10", }, }, defaultVariants: { @@ -68,7 +78,14 @@ export const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( - + ); }, ); diff --git a/clients/admin/src/components/ui/card.tsx b/clients/admin/src/components/ui/card.tsx index be40926f6f..098e8ac76e 100644 --- a/clients/admin/src/components/ui/card.tsx +++ b/clients/admin/src/components/ui/card.tsx @@ -2,22 +2,29 @@ import * as React from "react"; import { cn } from "@/lib/cn"; type CardProps = React.HTMLAttributes & { - /** When true, the card softens its shadow in on hover. */ + /** When true, the card softens its border + shadow on hover. */ interactive?: boolean; }; /** - * Card — primary surface. The `card-shell` utility (in globals.css) owns the - * visual treatment so cards across the app stay coherent; this component is - * mostly a typed forwardRef + interactive opt-in. + * Card — calm neutral surface. A 1px hairline + a low resting + * shadow that lifts the card off the canvas just enough to read as + * "sheet on a desk." Interactive variant darkens the border and adds + * a hint of lift on hover — no pillow shadows, no gradient borders. + * + * Note: `card-shell` and `card-shell-interactive` CSS utility classes + * (defined in globals.css) remain available for direct className usage + * in legacy admin consumers during phase migration. */ export const Card = React.forwardRef( ({ className, interactive, ...props }, ref) => (
>( ({ className, ...props }, ref) => ( -
+
), ); CardHeader.displayName = "CardHeader"; @@ -37,7 +49,8 @@ export const CardTitle = React.forwardRef (
), @@ -48,6 +61,7 @@ export const CardDescription = React.forwardRef (
@@ -57,7 +71,7 @@ CardDescription.displayName = "CardDescription"; export const CardContent = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
), ); CardContent.displayName = "CardContent"; @@ -66,10 +80,8 @@ export const CardFooter = React.forwardRef (
), diff --git a/clients/admin/src/components/ui/dialog.tsx b/clients/admin/src/components/ui/dialog.tsx index 066dc9907e..1818cc2221 100644 --- a/clients/admin/src/components/ui/dialog.tsx +++ b/clients/admin/src/components/ui/dialog.tsx @@ -4,8 +4,8 @@ import { X } from "lucide-react"; import { cn } from "@/lib/cn"; /** - * Dialog — Console-flavored Radix Dialog. - * Pattern: + * Dialog primitives — Radix-based, styled to the FSH design system. + * Usage: * * * @@ -16,6 +16,10 @@ import { cn } from "@/lib/cn"; * ... * * + * + * Open/close transitions are driven by [data-state] attributes Radix + * sets on the overlay and content, paired with the keyframes in + * globals.css. */ export const Dialog = DialogPrimitive.Root; @@ -29,9 +33,10 @@ export const DialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( , React.ComponentPropsWithoutRef & { - /** Max width — defaults to `md` (28rem). Use `lg`/`xl` for richer dialogs. */ + /** + * Max width — defaults to `md`. Preserved for admin call-site compat. + * `sm` = max-w-sm, `md` = max-w-lg (≈ dashboard default), `lg` = max-w-2xl, `xl` = max-w-4xl. + */ size?: "sm" | "md" | "lg" | "xl"; } >(({ className, children, size = "md", ...props }, ref) => { const sizeClass: Record, string> = { - sm: "max-w-sm", - md: "max-w-md", - lg: "max-w-2xl", - xl: "max-w-4xl", + sm: "sm:max-w-sm", + md: "sm:max-w-lg", + lg: "sm:max-w-2xl", + xl: "sm:max-w-4xl", }; return ( {children} - + @@ -88,14 +98,13 @@ DialogContent.displayName = "DialogContent"; export function DialogHeader({ className, ...props }: React.HTMLAttributes) { return ( -
+
); } -export function DialogBody({ className, ...props }: React.HTMLAttributes) { - return
; -} - export function DialogFooter({ className, ...props }: React.HTMLAttributes) { return (
)); DialogDescription.displayName = "DialogDescription"; + +export function DialogBody({ className, ...props }: React.HTMLAttributes) { + return
; +} + +// ───────────────────────────────────────────────────────────────────────── +// Sheet — edge-anchored Dialog variant. Same Radix primitive (so it gets +// focus-trap, scroll-lock, ESC dismissal, overlay click-to-close for free) +// but slides in from a side instead of centering. +// ───────────────────────────────────────────────────────────────────────── + +type SheetSide = "left" | "right" | "top" | "bottom"; + +const sheetSideClasses: Record = { + left: "inset-y-0 left-0 h-full w-[min(20rem,85vw)] border-r data-[state=open]:animate-fsh-sheet-in-left data-[state=closed]:animate-fsh-sheet-out-left", + right: "inset-y-0 right-0 h-full w-[min(20rem,85vw)] border-l data-[state=open]:animate-fsh-sheet-in-right data-[state=closed]:animate-fsh-sheet-out-right", + top: "inset-x-0 top-0 w-full max-h-[85vh] border-b data-[state=open]:animate-fsh-sheet-in-top data-[state=closed]:animate-fsh-sheet-out-top", + bottom: "inset-x-0 bottom-0 w-full max-h-[85vh] border-t data-[state=open]:animate-fsh-sheet-in-bottom data-[state=closed]:animate-fsh-sheet-out-bottom", +}; + +export const SheetContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + side?: SheetSide; + /** Render the built-in close button. Defaults to true. */ + showClose?: boolean; + } +>(({ className, children, side = "right", showClose = true, ...props }, ref) => ( + + + + {children} + {showClose && ( + + + + )} + + +)); +SheetContent.displayName = "SheetContent"; + +// Aliases for call-site clarity when using sheet semantics. +export const Sheet = DialogPrimitive.Root; +export const SheetTrigger = DialogPrimitive.Trigger; +export const SheetClose = DialogPrimitive.Close; diff --git a/clients/admin/src/components/ui/dropdown-menu.tsx b/clients/admin/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000..02ffb675c1 --- /dev/null +++ b/clients/admin/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { ChevronRight } from "lucide-react"; +import { cn } from "@/lib/cn"; + +/** + * Dropdown primitives — Radix-based, styled to the FSH design system. + * Trigger transitions are driven by [data-state] attributes Radix sets + * on the content, paired with the dialog-in/out keyframes from + * globals.css. Content uses the gradient-border + frosted treatment so + * it shares vocabulary with Card and Dialog. + */ + +export const DropdownMenu = DropdownMenuPrimitive.Root; +export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +export const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +export const DropdownMenuContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 6, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = "DropdownMenuContent"; + +export const DropdownMenuItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + destructive?: boolean; + } +>(({ className, destructive, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = "DropdownMenuItem"; + +export const DropdownMenuLinkItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + href: string; + } +>(({ href, children, ...props }, ref) => { + return ( + + + {children} + + + + ); +}); +DropdownMenuLinkItem.displayName = "DropdownMenuLinkItem"; + +export const DropdownMenuLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = "DropdownMenuLabel"; + +export const DropdownMenuSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = "DropdownMenuSeparator"; + +/** + * A non-interactive row meant to host arbitrary content (e.g. a + * segmented theme toggle). Sits on the same horizontal rail as items + * but doesn't participate in roving focus. + */ +export function DropdownMenuRow({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} diff --git a/clients/admin/src/components/ui/input.tsx b/clients/admin/src/components/ui/input.tsx index 32eb49f2f6..0071d0ea95 100644 --- a/clients/admin/src/components/ui/input.tsx +++ b/clients/admin/src/components/ui/input.tsx @@ -4,28 +4,33 @@ import { cn } from "@/lib/cn"; export type InputProps = React.InputHTMLAttributes; /** - * Input — flat bottom-border-emphasized field in the Console language. - * Resting state: hairline border, transparent fill. Hover: stronger border. - * Focus: signal-colored ring + subtle background lift. No floating labels; - * label sits above via the Label primitive. + * Input — h-9 hairline-bordered field with a 3px brand-tinted focus + * ring. Sits on transparent so it inherits the surface it's placed on + * (card vs. canvas); in dark mode picks up a faint input tint so + * inputs read as recessed wells on graphite. No floating-label + * monster — labels live above the field as plain
-
+ + {/* Mobile drawer — mounted at root so it portals above the shell. */} + + ); } diff --git a/clients/admin/src/components/layout/mobile-nav.tsx b/clients/admin/src/components/layout/mobile-nav.tsx index 9c16c33044..c055286731 100644 --- a/clients/admin/src/components/layout/mobile-nav.tsx +++ b/clients/admin/src/components/layout/mobile-nav.tsx @@ -1,101 +1,169 @@ -import { useEffect, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { useLocation } from "react-router-dom"; -import { Menu, X } from "lucide-react"; -import { SidebarContent } from "@/components/layout/sidebar-content"; +import { Menu } from "lucide-react"; +import { + Dialog as Sheet, + SheetContent, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { SidebarNavBody } from "@/components/layout/sidebar"; +import { findSectionForPath, sections, filterNavSpec } from "@/components/layout/nav-items"; +import { useAuth } from "@/auth/use-auth"; import { cn } from "@/lib/cn"; +import type { NavSection } from "@/components/layout/nav-items"; /** - * MobileNav — hamburger trigger + slide-over drawer for screens below `md`. - * Closes on route change, on Escape, and on backdrop click. The drawer - * uses the same SidebarContent as the desktop rail so numbering and - * active-state styling stay identical. + * Mobile nav drawer. + * + * Below `md`, the desktop is hidden. mounts + * a left-edge Sheet that the Topbar's hamburger triggers. The drawer reuses + * so there is one source of truth for the nav. + * + * Composition: + * + * ← renders the Sheet (mount once at root) + * ← the hamburger (place in Topbar) + * */ -export function MobileNav() { + +type MobileNavContextValue = { + open: boolean; + setOpen: (next: boolean) => void; +}; + +const MobileNavContext = createContext(null); + +export function useMobileNav() { + const ctx = useContext(MobileNavContext); + if (!ctx) throw new Error("useMobileNav must be used within MobileNavProvider"); + return ctx; +} + +export function MobileNavProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); + const value = useMemo(() => ({ open, setOpen }), [open]); + return ( + {children} + ); +} + +/** + * The Sheet itself — mount once near the root (inside the provider). + * Auto-closes on route changes. + */ +export function MobileNavRoot() { + const { open, setOpen } = useMobileNav(); const location = useLocation(); - const buttonRef = useRef(null); + const { user, permissionsHydrated } = useAuth(); + + const granted = permissionsHydrated ? (user?.permissions ?? []) : []; + const visibleSections: NavSection[] = useMemo( + () => + sections + .map((s) => ({ ...s, items: filterNavSpec(s.items, granted) })) + .filter((s) => s.items.length > 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [granted.join(",")], + ); + + const [openSection, setOpenSection] = useState(() => + findSectionForPath(location.pathname), + ); - // Close on route change. We intentionally listen on pathname only — the - // search/hash changing shouldn't dismiss the drawer. useEffect(() => { - setOpen(false); + setOpenSection(findSectionForPath(location.pathname)); }, [location.pathname]); - // Close on Escape; lock body scroll while open. + // Close the drawer on route changes (e.g. browser back/forward). useEffect(() => { - if (!open) return; - // Capture the trigger node now (it's stable) so the cleanup focuses the - // right element without reading a possibly-changed ref at teardown. - const trigger = buttonRef.current; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpen(false); - }; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - document.addEventListener("keydown", onKey); - return () => { - document.body.style.overflow = previousOverflow; - document.removeEventListener("keydown", onKey); - // Return focus to the trigger so the next tap-target is the menu button. - trigger?.focus(); - }; - }, [open]); + setOpen(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname]); return ( - <> - - - {/* Drawer + backdrop */} -
- {/* Backdrop — dim + slight blur. canvas-grid sits under it so the - console texture stays continuous through the dim. */} - - setOpen(false)} /> - -
- + F + +
+ + fullstackhero + + + Admin + +
+
+ + setOpen(false)} + /> + +
+

+ v0.1 · admin +

+
+ + ); } + +/** + * Hamburger trigger — `md:hidden`. Place in the Topbar. + */ +export function MobileNavTrigger({ className }: { className?: string }) { + const { setOpen } = useMobileNav(); + const onClick = useCallback(() => setOpen(true), [setOpen]); + return ( + + ); +} + +/** + * @deprecated Use MobileNavTrigger + MobileNavProvider + MobileNavRoot instead. + * Kept only for any lingering direct usages of the old . + */ +export { MobileNavTrigger as MobileNav }; diff --git a/clients/admin/src/components/layout/nav-items.ts b/clients/admin/src/components/layout/nav-items.ts index 0afda68aaf..c31223454c 100644 --- a/clients/admin/src/components/layout/nav-items.ts +++ b/clients/admin/src/components/layout/nav-items.ts @@ -4,6 +4,7 @@ import { LayoutDashboard, Receipt, ScrollText, + Settings, ShieldCheck, UserCog, UsersRound, @@ -17,28 +18,147 @@ import { MultitenancyPermissions, } from "@/lib/permissions"; -export type NavItem = { +/** A single nav destination — label, route, icon, optional perm guard. */ +export type NavSpec = { to: string; label: string; icon: LucideIcon; - /** Sub-path prefix that should also light this nav item. */ - matchPrefix?: string; - /** - * Permissions the user must hold to see this item in the sidebar. Mirrors - * the route's RouteGuard exactly — if the user can't reach the page they - * shouldn't see the link. Omit (or pass []) for surfaces every signed-in - * user can hit (Overview, Health). - */ + /** One or more permissions the user must hold to see this item. */ perms?: readonly string[]; }; -/** - * Primary navigation. Shared between the desktop sidebar and the mobile - * drawer so the magazine-table-of-contents numbering stays consistent. - * - * Per-entry `perms` are kept in lockstep with routes.tsx — see useVisibleNavItems - * for the filtering. If a route's gating changes, update both places. - */ +/** A collapsible section that groups related NavSpecs. */ +export type NavSection = { + id: string; + caption: string; + icon: LucideIcon; + items: NavSpec[]; +}; + +// ─── Top-level singletons ──────────────────────────────────────────────────── + +export const topNavTop: NavSpec[] = [ + { to: "/", label: "Overview", icon: LayoutDashboard }, +]; + +export const topNavBottom: NavSpec[] = [ + { to: "/settings", label: "Settings", icon: Settings }, +]; + +// ─── Section accordions ────────────────────────────────────────────────────── + +export const sections: NavSection[] = [ + { + id: "multitenancy", + caption: "Tenants", + icon: Building2, + items: [ + { + to: "/tenants", + label: "Tenants", + icon: Building2, + perms: [MultitenancyPermissions.Tenants.View], + }, + ], + }, + { + id: "identity", + caption: "Identity", + icon: UsersRound, + items: [ + { + to: "/users", + label: "Users", + icon: UsersRound, + perms: [IdentityPermissions.Users.View], + }, + { + to: "/roles", + label: "Roles", + icon: ShieldCheck, + perms: [IdentityPermissions.Roles.View], + }, + { + to: "/impersonation", + label: "Impersonation", + icon: UserCog, + perms: [IdentityPermissions.Impersonation.View], + }, + ], + }, + { + id: "operations", + caption: "Operations", + icon: Activity, + items: [ + { + to: "/billing", + label: "Billing", + icon: Receipt, + perms: [BillingPermissions.View], + }, + { + to: "/webhooks", + label: "Webhooks", + icon: Webhook, + }, + { + to: "/audits", + label: "Audits", + icon: ScrollText, + perms: [AuditingPermissions.AuditTrails.View], + }, + { + to: "/health", + label: "Health", + icon: Activity, + }, + ], + }, +]; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Find the section id whose items contain the given path (best prefix match). */ +export function findSectionForPath(pathname: string): string | null { + let bestId: string | null = null; + let bestLen = 0; + for (const s of sections) { + for (const item of s.items) { + if ( + (item.to === "/" && pathname === "/") || + (item.to !== "/" && pathname.startsWith(item.to)) + ) { + if (item.to.length > bestLen) { + bestLen = item.to.length; + bestId = s.id; + } + } + } + } + return bestId; +} + +/** Returns true when the given NavSpec is the active route. */ +export function isNavItemActive(item: NavSpec, pathname: string): boolean { + if (item.to === "/") return pathname === "/"; + return pathname === item.to || pathname.startsWith(`${item.to}/`); +} + +/** Filter nav items (and sections) based on granted permissions. */ +export function filterNavSpec(items: NavSpec[], granted: readonly string[]): NavSpec[] { + return items.filter((item) => { + if (!item.perms || item.perms.length === 0) return true; + return item.perms.every((p) => granted.includes(p)); + }); +} + +// ── Legacy flat export (used by sidebar-content & permission gating elsewhere) ── + +/** @deprecated Use sections / topNavTop / topNavBottom instead. */ +export type NavItem = NavSpec & { matchPrefix?: string }; + +/** @deprecated Flat list kept only for call-sites still importing NAV_ITEMS. */ export const NAV_ITEMS: NavItem[] = [ { to: "/", label: "Overview", icon: LayoutDashboard }, { @@ -92,13 +212,7 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/health", label: "Health", icon: Activity, matchPrefix: "/health" }, ]; -export function isNavItemActive(item: NavItem, pathname: string): boolean { - if (item.matchPrefix) { - return pathname === item.matchPrefix || pathname.startsWith(`${item.matchPrefix}/`); - } - return pathname === item.to; -} - +/** @deprecated Use filterNavSpec instead. */ export function filterNavItems(items: NavItem[], grantedPermissions: readonly string[]): NavItem[] { return items.filter((item) => { if (!item.perms || item.perms.length === 0) return true; diff --git a/clients/admin/src/components/layout/sidebar-content.tsx b/clients/admin/src/components/layout/sidebar-content.tsx index 819e3c6a88..54af66d4cc 100644 --- a/clients/admin/src/components/layout/sidebar-content.tsx +++ b/clients/admin/src/components/layout/sidebar-content.tsx @@ -1,96 +1,8 @@ -import { useMemo } from "react"; -import { NavLink, useLocation } from "react-router-dom"; -import { BrandMark } from "@/components/brand-mark"; -import { NAV_ITEMS, filterNavItems, isNavItemActive } from "@/components/layout/nav-items"; -import { useAuth } from "@/auth/use-auth"; -import { cn } from "@/lib/cn"; - -type SidebarContentProps = { - /** Optional click hook — used by the mobile drawer to close on navigate. */ - onNavigate?: () => void; -}; - /** - * SidebarContent — the inner nav layout shared between the desktop Sidebar - * and the mobile drawer. Magazine TOC numbering (01, 02, …), chartreuse - * active-rail on the left of the selected entry, mono footer kicker. + * sidebar-content.tsx — compatibility shim. + * + * The sidebar's nav body now lives in sidebar.tsx as . + * This file is kept so any lingering imports (e.g. tests) don't break. + * Mobile nav uses directly. */ -export function SidebarContent({ onNavigate }: SidebarContentProps) { - const location = useLocation(); - const { user, permissionsHydrated } = useAuth(); - // Render the unfiltered list while permissions are still loading so the - // sidebar doesn't flash empty on cold-start. Filter once they're known. - const items = useMemo(() => { - if (!permissionsHydrated) return NAV_ITEMS; - return filterNavItems(NAV_ITEMS, user?.permissions ?? []); - }, [permissionsHydrated, user?.permissions]); - return ( -
- {/* Brand block */} -
- -
- - {/* Section marker */} -
-
// Navigation
-
- - {/* Nav */} - - - {/* Footer credit */} -
-
v0.1 · console
-
- platform · administration · interface -
-
-
- ); -} +export { SidebarNavBody as SidebarContent } from "@/components/layout/sidebar"; diff --git a/clients/admin/src/components/layout/sidebar.tsx b/clients/admin/src/components/layout/sidebar.tsx index e5e8259fe6..ce3e5aba82 100644 --- a/clients/admin/src/components/layout/sidebar.tsx +++ b/clients/admin/src/components/layout/sidebar.tsx @@ -1,14 +1,423 @@ -import { SidebarContent } from "@/components/layout/sidebar-content"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { NavLink, useLocation } from "react-router-dom"; +import { ChevronDown, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useAuth } from "@/auth/use-auth"; +import { + findSectionForPath, + filterNavSpec, + sections, + topNavBottom, + topNavTop, + type NavSection, + type NavSpec, +} from "@/components/layout/nav-items"; + +const COLLAPSED_KEY = "fsh.admin.sidebar.collapsed"; + +/** Persisted collapsed state, reads localStorage on mount. */ +function useCollapsedSidebar() { + const [collapsed, setRaw] = useState(() => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(COLLAPSED_KEY) === "true"; + } catch { + return false; + } + }); + const setCollapsed = useCallback((next: boolean) => { + setRaw(next); + try { + window.localStorage.setItem(COLLAPSED_KEY, String(next)); + } catch { + /* storage unavailable */ + } + }, []); + return { collapsed, toggle: () => setCollapsed(!collapsed) }; +} -/** - * Sidebar — desktop-only fixed-width rail. Below `md` the AppShell mounts - * instead, which uses the same in a - * slide-over drawer. - */ export function Sidebar() { + const { collapsed, toggle } = useCollapsedSidebar(); + const location = useLocation(); + const { user, permissionsHydrated } = useAuth(); + + // Permission-filtered sections + const granted = permissionsHydrated ? (user?.permissions ?? []) : []; + const visibleSections: NavSection[] = useMemo( + () => + sections + .map((s) => ({ ...s, items: filterNavSpec(s.items, granted) })) + .filter((s) => s.items.length > 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [granted.join(",")], + ); + + // Single-select accordion: which section is currently open. + const [openSection, setOpenSection] = useState(() => + findSectionForPath(location.pathname), + ); + + // Re-sync the open section on every route change. + useEffect(() => { + const next = findSectionForPath(location.pathname); + setOpenSection(next); + }, [location.pathname]); + return ( -
); } @@ -741,17 +742,52 @@ function MessageActions({ <> - {pickerOpen && ( -
- {QUICK_REACTIONS.map((emoji) => ( - - ))} -
- )} - {editing && ( void; - destructive?: boolean; - children: React.ReactNode; -}) { +// forwardRef + prop spread so this can serve as a Radix `asChild` trigger +// (the React button is a DropdownMenuTrigger) — Radix injects ref, onClick, +// and aria/data-state props that must reach the underlying ); -} +}); function EditMessageInline({ message, @@ -965,7 +970,7 @@ function EditMessageInline({ }; return ( -
+
Date: Thu, 28 May 2026 04:32:26 +0530 Subject: [PATCH 07/15] =?UTF-8?q?feat(admin):=20phase=204=20=E2=80=94=20mi?= =?UTF-8?q?grate=20all=20pages=20to=20the=20dashboard=20vocabulary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyle every admin page onto the dashboard's section/field/page-header vocabulary (EntityPageHeader, SettingsSection, Field, list cards), retiring the FormShell/FormSection 18rem rail, `\ SECTION` markers, and console classes: - settings/* (profile now uses the presigned ImageInput — fixes the data-URL avatar bug), roles/*, users/*, tenants/* (list now matches the users/roles card-table + mobile-card pattern), billing/*, webhooks/*, audits/*, notifications, health, impersonation, auth/*, login, not-found, dashboard. Also re-applies the PR #1267 fixes that this branch (cut from main) was missing: - listRoles() unwraps the paged response (fixes the users/roles `.map` crash). - /health added to the Vite dev proxy. - tenant detail treats a 404 provisioning status as a neutral "Not tracked" state (no retry/poll storm) instead of a red FAILURE. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) --- clients/admin/src/api/roles.ts | 8 +- clients/admin/src/pages/audits/detail.tsx | 226 ++++----- clients/admin/src/pages/audits/list.tsx | 37 +- .../admin/src/pages/auth/confirm-email.tsx | 218 +++++---- .../admin/src/pages/auth/forgot-password.tsx | 329 ++++++++----- .../admin/src/pages/auth/reset-password.tsx | 362 +++++++++----- .../src/pages/billing/invoice-detail.tsx | 382 ++++++--------- clients/admin/src/pages/billing/layout.tsx | 30 +- clients/admin/src/pages/billing/plan-form.tsx | 251 ++++------ .../admin/src/pages/billing/plans-list.tsx | 178 ++++--- clients/admin/src/pages/dashboard.tsx | 152 +++--- clients/admin/src/pages/health/page.tsx | 53 +-- .../admin/src/pages/impersonation/list.tsx | 34 +- clients/admin/src/pages/login.tsx | 387 +++++++-------- clients/admin/src/pages/not-found.tsx | 89 +++- .../admin/src/pages/notifications/inbox.tsx | 54 +-- clients/admin/src/pages/roles/create.tsx | 120 ++--- clients/admin/src/pages/roles/detail.tsx | 381 ++++++++------- clients/admin/src/pages/roles/list.tsx | 284 ++++++++--- .../admin/src/pages/settings/appearance.tsx | 82 +++- clients/admin/src/pages/settings/layout.tsx | 14 +- clients/admin/src/pages/settings/profile.tsx | 276 ++++------- clients/admin/src/pages/settings/security.tsx | 445 +++++++++++------- clients/admin/src/pages/settings/sessions.tsx | 101 ++-- clients/admin/src/pages/tenants/create.tsx | 132 +++--- clients/admin/src/pages/tenants/detail.tsx | 231 ++++----- clients/admin/src/pages/tenants/list.tsx | 282 +++++++---- clients/admin/src/pages/users/create.tsx | 233 ++++----- clients/admin/src/pages/users/detail.tsx | 379 ++++++++------- clients/admin/src/pages/users/list.tsx | 368 ++++++++++----- clients/admin/src/pages/webhooks/detail.tsx | 141 +++--- clients/admin/src/pages/webhooks/list.tsx | 41 +- clients/admin/vite.config.ts | 1 + 33 files changed, 3531 insertions(+), 2770 deletions(-) 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/audits/detail.tsx b/clients/admin/src/pages/audits/detail.tsx index f38701682f..e619f6914a 100644 --- a/clients/admin/src/pages/audits/detail.tsx +++ b/clients/admin/src/pages/audits/detail.tsx @@ -9,34 +9,25 @@ import { Copy, FileText, Fingerprint, + ScrollText, } from "lucide-react"; import { getAudit, type AuditDetailDto } from "@/api/audits"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { PageHeader, ErrorBand, LoadingRow } from "@/components/list"; +import { EntityPageHeader, ErrorBand, LoadingRow, SettingsSection } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; /** * Audit detail — forensic record view. * - * Layout is purpose-built for the data shape (12+ short metadata fields - * + one large JSON payload) rather than the generic FormShell aside-rail - * pattern, which left huge horizontal whitespace and let long stack-trace - * lines blow out the page width. - * - * Sections, top to bottom: - * 1. Identity strip — h1 + event-type + severity chips + occurred-at - * 2. Correlation strip — trace / span / correlation / request IDs as - * copyable chips. Operators paste these into Grafana / Loki / Jaeger - * as their first move, so they get top billing. - * 3. Context grid — auto-fit fact tiles for everything else. - * 4. Payload viewer — full-width JSON pane with bounded inner scroll - * (both axes) so no payload, however ugly, ever pushes the page wider - * than the viewport. - * - * Page caps at `max-w-7xl mx-auto` so it doesn't sprawl on ultra-wide - * monitors; sections themselves are full width inside that cap. + * Layout top to bottom: + * 1. EntityPageHeader — icon + event-type + occurred-at + * 2. Identity strip — event-type + severity chips + source + * 3. Correlation strip — trace / span / correlation / request IDs as + * copyable chips. + * 4. Context grid — fact tiles. + * 5. Payload viewer — full-width JSON pane with bounded inner scroll. */ export function AuditDetailPage() { const { id } = useParams<{ id: string }>(); @@ -52,24 +43,20 @@ export function AuditDetailPage() { return (
- navigate(-1)}> - Back - - } - /> +
+ + +
{query.isError && ( - - {eventLabel} - - - {event.severity} - - {event.source && ( - - source · - {event.source} + +
+ + {eventLabel} + + + {event.severity} + + {event.source && ( + + source · + {event.source} + + )} + + {formatTimestamp(event.occurredAtUtc)} - )} - - {formatTimestamp(event.occurredAtUtc)} - - +
+
); } // ───────────────────────────────────────────────────────────────────────── -// 2. Correlation band — the most-used widget on the page. Each chip is a -// self-contained copy-to-clipboard target so operators can fire IDs -// into Grafana / Loki / Jaeger without text-selecting from a table row. +// 2. Correlation band — the most-used widget on the page. // ───────────────────────────────────────────────────────────────────────── function CorrelationBand({ event }: { event: AuditDetailDto }) { @@ -150,22 +131,17 @@ function CorrelationBand({ event }: { event: AuditDetailDto }) { ]; return ( -
-
- - - {"\\ Correlation"} - - - paste into your observability stack - -
-
+ +
{slots.map((s) => ( ))}
-
+ ); } @@ -198,7 +174,7 @@ function CorrelationChip({ label, value }: { label: string; value: string | null aria-label={hasValue ? `Copy ${label}` : `${label} not available`} >
-
+
{label}
@@ -222,9 +198,7 @@ function CorrelationChip({ label, value }: { label: string; value: string | null } // ───────────────────────────────────────────────────────────────────────── -// 3. Context grid — auto-fit fact tiles. minmax(15rem, 1fr) gives ~4 cols -// on a wide editor pane, ~2 on a tablet, ~1 on phone, without any -// media queries. Each tile is self-contained so it can wrap freely. +// 3. Context grid — auto-fit fact tiles. // ───────────────────────────────────────────────────────────────────────── function ContextGrid({ event }: { event: AuditDetailDto }) { @@ -244,25 +218,20 @@ function ContextGrid({ event }: { event: AuditDetailDto }) { ]; return ( -
-
- - - {"\\ Context"} - - - who, where, when — the surrounding facts - -
+
{tiles.map((t) => ( ))}
-
+ ); } @@ -277,7 +246,7 @@ function FactTile({ }) { return (
-
{label}
+
{label}
(whitespace: pre, default) reports its natural width -// to the parent grid/flex which then exceeds the page, forcing a -// horizontal scroll on the whole document. With min-w-0 in place the -// pre's own overflow-x:auto correctly takes over. // ───────────────────────────────────────────────────────────────────────── function PayloadPanel({ payload }: { payload: unknown }) { @@ -317,43 +280,33 @@ function PayloadPanel({ payload }: { payload: unknown }) { const lineCount = useMemo(() => json.split("\n").length, [json]); return ( -
-
- - - {"\\ Payload"} - -
- - application/json · {lineCount} lines - - -
-
- - {/* Gutter + scrollable code. The outer wrapper sets `min-w-0` so the - page never widens past the viewport regardless of payload shape; - the inner
 then scrolls horizontally on its own. */}
-      
-
+          {copied ? (
+            <>
+               Copied
+            
+          ) : (
+            <>
+               Copy JSON
+            
           )}
+        
+      }
+    >
+      {/* min-w-0 prevents the pre from blowing out the page width */}
+      
+
           {json}
         
-
+ ); } @@ -362,9 +315,6 @@ function PayloadPanel({ payload }: { payload: unknown }) { // ───────────────────────────────────────────────────────────────────────── function formatEventType(raw: unknown): string { - // Defensive — the API boundary coerces to a string union, but if an - // unknown shape ever sneaks through we render something instead of - // crashing on .toUpperCase(). return typeof raw === "string" && raw.length > 0 ? raw : "Event"; } diff --git a/clients/admin/src/pages/audits/list.tsx b/clients/admin/src/pages/audits/list.tsx index 0f2b11a243..ab8b8d104e 100644 --- a/clients/admin/src/pages/audits/list.tsx +++ b/clients/admin/src/pages/audits/list.tsx @@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { - PageHeader, + EntityPageHeader, ErrorBand, Pagination, StatStrip, @@ -125,27 +125,24 @@ export function AuditsListPage() { return (
- query.refetch()} - > - - Refresh - - } - /> + > + + diff --git a/clients/admin/src/pages/auth/confirm-email.tsx b/clients/admin/src/pages/auth/confirm-email.tsx index 3348e00c9e..b67b59c01e 100644 --- a/clients/admin/src/pages/auth/confirm-email.tsx +++ b/clients/admin/src/pages/auth/confirm-email.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; -import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"; +import { AlertCircle, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { AuthShell } from "@/components/auth/auth-shell"; import { confirmEmail } from "@/api/users"; import { ApiRequestError } from "@/lib/api-client"; @@ -15,9 +14,9 @@ type Status = * Confirm-email landing — admin variant. * * Same shape as the dashboard's: auto-fire GET on mount, surface stable - * success or failure state with recovery affordances. Uses the admin's - * editorial split-screen shell so the page reads as part of the same - * surface as the login page. + * success or failure state with recovery affordances. Uses the unified + * centered-card auth shell so the page reads as part of the same surface + * as the login and other auth pages. */ export function ConfirmEmailPage() { const [params] = useSearchParams(); @@ -63,88 +62,147 @@ export function ConfirmEmailPage() { }, [userId, code, tenant, malformed]); return ( - - {status.kind === "loading" && ( -
-
- - +
+ {/* Atmospheric background orbs */} +
+
+
+
+
+ + {/* Card column */} +
+ {/* Brand lockup */} +
+
+ FullStackHero + + fullstackhero
+
+ + .NET 10 Starter Kit + +
- )} - {status.kind === "success" && ( -
-
- - - + {/* Status card */} +
+
+ {status.kind === "loading" && ( +
+
+ + + +
+
+

+ Verifying your{" "} + email… +

+

+ One moment — checking the confirmation token with the server. +

+
+
+ )} + + {status.kind === "success" && ( +
+
+ + + +
+
+

+ Email{" "} + confirmed +

+

+ {status.message} +

+
+ + + +
+ )} + + {status.kind === "error" && ( +
+
+ + + +
+
+

+ Couldn't{" "} + confirm{" "} + your email +

+

+ {status.message} +

+

+ The link may have expired or been used already. If you've signed in since + this email was sent, you can ignore it. +

+
+
+ + + + + + +
+
+ )}
-

- {status.message} -

- - -
- )} - {status.kind === "error" && ( -
-
- - - -
-

- {status.message} -

-

- The link may have expired or been used already. If you've signed in since - this email was sent, you can ignore it. -

-
- - - - - - -
+
+ + ← Back to sign in +
- )} - +
+
); } diff --git a/clients/admin/src/pages/auth/forgot-password.tsx b/clients/admin/src/pages/auth/forgot-password.tsx index ba2ab5fbaf..4264153963 100644 --- a/clients/admin/src/pages/auth/forgot-password.tsx +++ b/clients/admin/src/pages/auth/forgot-password.tsx @@ -1,14 +1,23 @@ import { useState, type FormEvent } from "react"; import { Link, Navigate } from "react-router-dom"; import { useMutation } from "@tanstack/react-query"; -import { AlertCircle, Check, MailCheck } from "lucide-react"; +import { + AlertCircle, + ArrowRight, + Building2, + Check, + Loader2, + Mail, + MailCheck, + ShieldCheck, +} from "lucide-react"; import { useAuth } from "@/auth/use-auth"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { AuthShell } from "@/components/auth/auth-shell"; import { requestPasswordReset } from "@/api/users"; import { ApiRequestError } from "@/lib/api-client"; +import { cn } from "@/lib/cn"; import { env } from "@/env"; /** @@ -46,124 +55,218 @@ export function ForgotPasswordPage() { }; return ( - - {submitted ? ( -
-
- - +
+ {/* Atmospheric background orbs */} +
+
+
+
+
+ + {/* Card column */} +
+ {/* Brand lockup */} +
+
+ FullStackHero + + fullstackhero
-
-

Check your inbox.

-

- If an account exists for{" "} - - {email} - {" "} - in tenant{" "} - - {tenant} - - , a one-time reset link is on its way. The link expires in 30 minutes. -

-
-
    -
  • - - Didn't get it? Wait a minute, then check spam. -
  • -
  • - - Still nothing? Confirm the email + tenant and retry. -
  • -
-
- - - - +
+ + .NET 10 Starter Kit +
- ) : ( -
-
- - setTenant(e.target.value)} - required - autoComplete="organization" - placeholder="root" - /> -
-
- - setEmail(e.target.value)} - required - autoComplete="email" - autoFocus - placeholder="operator@root.example" - /> -
- {error && ( -
- - {error} -
- )} + {/* Form card */} +
+
+ {submitted ? ( +
+
+ + + +
+
+

+ Check your{" "} + inbox +

+

+ If an account exists for{" "} + {email} in tenant{" "} + {tenant}, a one-time + reset link is on its way. The link expires in 30 minutes. +

+
+
    +
  • + + Didn't get it? Wait a minute, then check spam. +
  • +
  • + + Still nothing? Confirm the email + tenant and retry. +
  • +
+
+ + + + +
+
+ ) : ( + <> +
+

+ Reset your{" "} + password +

+

+ Enter the email + tenant you sign in with. We'll dispatch a one-time link. +

+
- + +
+ +
+ + setTenant(e.target.value)} + required + autoComplete="organization" + placeholder="root" + aria-invalid={error ? true : undefined} + aria-describedby={error ? "forgot-error" : undefined} + className="h-11 pl-10 text-[14px]" + /> +
+
+
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + autoFocus + placeholder="operator@root.example" + aria-invalid={error ? true : undefined} + aria-describedby={error ? "forgot-error" : undefined} + className="h-11 pl-10 text-[14px]" + /> +
+
-
- Remembered it?{" "} - - Sign in - + {error && ( + + )} + +
+ +
+ + + )}
- - )} - +
+ +
+ Remembered it?{" "} + + Sign in + +
+ +
+ + Encrypted in transit · JWT-secured session +
+
+
); } diff --git a/clients/admin/src/pages/auth/reset-password.tsx b/clients/admin/src/pages/auth/reset-password.tsx index 5c0b2959bf..610f3a3ba0 100644 --- a/clients/admin/src/pages/auth/reset-password.tsx +++ b/clients/admin/src/pages/auth/reset-password.tsx @@ -1,13 +1,20 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"; import { useMutation } from "@tanstack/react-query"; -import { AlertCircle, Check } from "lucide-react"; +import { + AlertCircle, + ArrowRight, + Check, + Eye, + EyeOff, + Loader2, + ShieldCheck, +} from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/auth/use-auth"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { AuthShell } from "@/components/auth/auth-shell"; import { resetPassword } from "@/api/users"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; @@ -47,6 +54,8 @@ export function ResetPasswordPage() { const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); const strength = useMemo(() => scorePassword(password), [password]); const matches = password.length > 0 && password === confirm; @@ -87,135 +96,236 @@ export function ResetPasswordPage() { mutation.mutate(); }; - if (malformed) { - return ( - -
-

- The link is missing one of{" "} - token,{" "} - email, or{" "} - tenant. - Some email clients clip long URLs — try copy-pasting the full link from the - original email into your browser's address bar, or request a new one. -

-
- - - - - - + return ( +
+ {/* Atmospheric background orbs */} +
+
+
+
+
+ + {/* Card column */} +
+ {/* Brand lockup */} +
+
+ FullStackHero + + fullstackhero + +
+
+ + .NET 10 Starter Kit +
- - ); - } - return ( - - Resetting password for{" "} - {email} on{" "} - - {tenant} - - . - - } - > -
-
- - setPassword(e.target.value)} - required - autoComplete="new-password" - autoFocus - minLength={8} - className="font-mono" - /> - {strength && ( -
-
-
+ {/* Form card */} +
+
+ {malformed ? ( +
+
+

+ This link is{" "} + incomplete +

+

+ The link is missing one of{" "} + token,{" "} + email, or{" "} + tenant. Some email + clients clip long URLs — try copy-pasting the full link from the original + email into your browser's address bar, or request a new one. +

+
+
+ + + + + + +
- - {STRENGTH_META[strength].label} - -
- )} -
+ ) : ( + <> +
+

+ Set a new{" "} + password +

+

+ Resetting password for{" "} + {email} on{" "} + {tenant}. +

+
-
- - setConfirm(e.target.value)} - required - autoComplete="new-password" - minLength={8} - className="font-mono" - /> - {confirm.length > 0 && ( -
- - {matches ? "Passwords match" : "Doesn't match yet"} -
- )} -
+ +
+ +
+ setPassword(e.target.value)} + required + autoComplete="new-password" + autoFocus + minLength={8} + placeholder="At least 8 characters" + aria-invalid={error ? true : undefined} + aria-describedby={error ? "reset-error" : undefined} + className="h-11 pr-11 text-[14px]" + /> + +
+ {strength && ( +
+
+
+
+ + {STRENGTH_META[strength].label} + +
+ )} +
- {error && ( -
- - {error} +
+ +
+ setConfirm(e.target.value)} + required + autoComplete="new-password" + minLength={8} + placeholder="Re-enter password" + aria-invalid={error ? true : undefined} + aria-describedby={error ? "reset-error" : undefined} + className="h-11 pr-11 text-[14px]" + /> + +
+ {confirm.length > 0 && ( +
+ + {matches ? "Passwords match" : "Doesn't match yet"} +
+ )} +
+ + {error && ( + + )} + +
+ +
+ + + )}
- )} - - - -
+
+ +
Changed your mind?{" "}
- - +
+
); } diff --git a/clients/admin/src/pages/billing/invoice-detail.tsx b/clients/admin/src/pages/billing/invoice-detail.tsx index 1143617ec4..d75034a8cb 100644 --- a/clients/admin/src/pages/billing/invoice-detail.tsx +++ b/clients/admin/src/pages/billing/invoice-detail.tsx @@ -1,29 +1,21 @@ import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Ban, CheckCircle2, Send } from "lucide-react"; +import { ArrowLeft, Ban, CheckCircle2, FileText, Send } from "lucide-react"; import { toast } from "sonner"; import { getInvoice, issueInvoice, markInvoicePaid, voidInvoice, - type InvoiceDto, type InvoiceStatus, type InvoiceLineItemDto, } from "@/api/billing"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { EntityPageHeader, SettingsSection, Field } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; @@ -131,7 +123,7 @@ export function InvoiceDetailPage() { return (
- @@ -145,190 +137,174 @@ export function InvoiceDetailPage() { {describe(query.error, "Failed to load invoice.")}
) : invoice ? ( - + + + {invoice.invoiceNumber} + + {invoice.status} + + tenant {invoice.tenantId} · period {formatPeriod(invoice.periodYear, invoice.periodMonth)} · created {formatDate(invoice.createdAtUtc)} + {invoice.issuedAtUtc && ` · issued ${formatDate(invoice.issuedAtUtc)}`} + {invoice.dueAtUtc && invoice.status === "Issued" && ( + · due {formatDate(invoice.dueAtUtc)} + )} + {invoice.paidAtUtc && ( + · paid {formatDate(invoice.paidAtUtc)} + )} + {invoice.voidedAtUtc && ( + · voided {formatDate(invoice.voidedAtUtc)} + )} + + + } + /> ) : null}
{/* Line items */} - - - Line items - - {invoice ? `${invoice.lineItems.length} line${invoice.lineItems.length === 1 ? "" : "s"}` : "Loading…"} - - - - {query.isLoading ? ( -
    - {Array.from({ length: 2 }).map((_, i) => ( -
  • - - -
  • - ))} -
- ) : invoice && invoice.lineItems.length === 0 ? ( -
- No line items. -
- ) : invoice ? ( -
    - {invoice.lineItems.map((li, i) => ( - - ))} -
  • -
    - subtotal -
    -
    - {formatMoney(invoice.subtotalAmount, invoice.currency)} -
    + + {query.isLoading ? ( +
      + {Array.from({ length: 2 }).map((_, i) => ( +
    • + +
    • -
    - ) : null} - - + ))} +
+ ) : invoice && invoice.lineItems.length === 0 ? ( +
+ No line items. +
+ ) : invoice ? ( +
    + {invoice.lineItems.map((li, i) => ( + + ))} +
  • +
    + subtotal +
    +
    + {formatMoney(invoice.subtotalAmount, invoice.currency)} +
    +
  • +
+ ) : null} + {/* Actions side panel */}
- - - Lifecycle actions - - Each action enforces the invoice state machine on the server. - - - - {invoice && ( - <> - {/* Issue */} -
+ {/* Issue */} + +
+ + setDueAt(e.target.value)} + disabled={invoice.status !== "Draft" || issueMutation.isPending} + /> + + -
-
+ {issueMutation.isPending ? "Issuing…" : "Issue invoice"} + +
+ - {/* Mark paid */} -
+
+ -
+ {payMutation.isPending ? "Saving…" : "Mark as paid"} + +
+ - {/* Void */} -
+
+ + setVoidReason(e.target.value)} + disabled={ + invoice.status === "Paid" || + invoice.status === "Void" || + voidMutation.isPending + } + /> + + -
-
+ {voidMutation.isPending ? "Voiding…" : "Void invoice"} + +
+ - {invoice.notes && ( -
-
- notes -
-

- {invoice.notes} -

-
- )} - + {invoice.notes && ( + +

+ {invoice.notes} +

+
)} - - + + )}
@@ -337,51 +313,6 @@ export function InvoiceDetailPage() { // ─── subcomponents ─────────────────────────────────────────────────── -function InvoiceHero({ invoice }: { invoice: InvoiceDto }) { - return ( -
-
- - {invoice.invoiceNumber} - - {invoice.status} -
-

- {formatMoney(invoice.subtotalAmount, invoice.currency)} -

-
- tenant {invoice.tenantId} · - period {formatPeriod(invoice.periodYear, invoice.periodMonth)} · - created {formatDate(invoice.createdAtUtc)} - {invoice.issuedAtUtc && ( - <> - {" · "} - issued {formatDate(invoice.issuedAtUtc)} - - )} - {invoice.dueAtUtc && invoice.status === "Issued" && ( - <> - {" · "} - due {formatDate(invoice.dueAtUtc)} - - )} - {invoice.paidAtUtc && ( - <> - {" · "} - paid {formatDate(invoice.paidAtUtc)} - - )} - {invoice.voidedAtUtc && ( - <> - {" · "} - voided {formatDate(invoice.voidedAtUtc)} - - )} -
-
- ); -} - function LineItemRow({ item, currency, @@ -393,7 +324,7 @@ function LineItemRow({ }) { return (
  • @@ -418,3 +349,4 @@ function LineItemRow({
  • ); } + diff --git a/clients/admin/src/pages/billing/layout.tsx b/clients/admin/src/pages/billing/layout.tsx index 543c463de1..08762e5475 100644 --- a/clients/admin/src/pages/billing/layout.tsx +++ b/clients/admin/src/pages/billing/layout.tsx @@ -1,5 +1,7 @@ import { NavLink, Outlet } from "react-router-dom"; +import { CreditCard } from "lucide-react"; import { cn } from "@/lib/cn"; +import { EntityPageHeader } from "@/components/list"; type Tab = { to: string; label: string }; @@ -10,31 +12,17 @@ const TABS: Tab[] = [ /** * BillingLayout — page hero + horizontal tabbed sub-nav. Child routes render - * inside ``. Hero uses the editorial section-rule so Billing slots - * into the existing admin language even with the new chromatic status chrome - * landing in the body content below. + * inside ``. */ export function BillingLayout() { return (
    -
    -
    - // BILLING - - platform pricing & ledger - -
    -
    -
    -

    - Billing -

    -

    - Manage plans, subscriptions, and invoices across every tenant on this instance. -

    -
    -
    -
    +
    ); } diff --git a/clients/admin/src/pages/billing/plans-list.tsx b/clients/admin/src/pages/billing/plans-list.tsx index e51417a199..647980d430 100644 --- a/clients/admin/src/pages/billing/plans-list.tsx +++ b/clients/admin/src/pages/billing/plans-list.tsx @@ -1,13 +1,12 @@ import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { Pencil, Plus } from "lucide-react"; +import { Pencil, Plus, Tag } from "lucide-react"; import { getPlans, type BillingPlanDto } from "@/api/billing"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; -import { KpiTile } from "@/components/kpi-tile"; +import { StatStrip, Stat, SettingsSection } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; // ─── helpers ────────────────────────────────────────────────────────── @@ -38,15 +37,11 @@ function describe(err: unknown): string { export function PlansListPage() { const navigate = useNavigate(); - // Plans are typically a small set — fetch everything (including inactive) so - // admins can see deactivated rows and the average-price KPI reflects truth. const query = useQuery({ queryKey: ["billing", "plans", { includeInactive: true }], queryFn: () => getPlans(true), }); - // useMemo dependencies need a stable reference; wrap the optional list once - // so the totals memo can depend on it without a fresh array on each render. const plans = useMemo(() => query.data ?? [], [query.data]); const totals = useMemo(() => { @@ -66,18 +61,18 @@ export function PlansListPage() { return (
    {/* KPI strip */} -
    - + : totals.count} - subtitle={`${totals.active} active`} + hint={`${totals.active} active`} /> - : totals.active} - subtitle={totals.count - totals.active > 0 ? `${totals.count - totals.active} inactive` : "all active"} + hint={totals.count - totals.active > 0 ? `${totals.count - totals.active} inactive` : "all active"} /> - -
    + - {/* List card */} - - -
    - All plans - - Pricing schedule used by tenant subscriptions and invoice generation. - -
    + {/* Plans list */} + navigate("/billing/plans/new")}> New plan -
    - - {query.isError && ( -
    - {describe(query.error)} -
    - )} + } + > + {query.isError && ( +
    + {describe(query.error)} +
    + )} - {query.isLoading ? ( -
      - {Array.from({ length: 3 }).map((_, i) => ( -
    • - - -
    • - ))} -
    - ) : plans.length === 0 ? ( -
    - No plans yet. Create your first plan to start charging tenants. -
    - ) : ( -
      - {plans.map((plan, i) => ( -
    • - {/* Identity column */} -
      -
      - - {plan.key} - - {plan.name} - {plan.isActive ? ( - Active - ) : ( - Inactive - )} -
      -
      - currency {plan.currency} · - {" "} - overage {formatOverageRates(plan.overageRates, plan.currency)} -
      + {query.isLoading ? ( +
        + {Array.from({ length: 3 }).map((_, i) => ( +
      • + + +
      • + ))} +
      + ) : plans.length === 0 ? ( +
      + No plans yet. Create your first plan to start charging tenants. +
      + ) : ( +
        + {plans.map((plan, i) => ( +
      • + {/* Identity column */} +
        +
        + + {plan.key} + + {plan.name} + {plan.isActive ? ( + Active + ) : ( + Inactive + )} +
        +
        + currency {plan.currency} ·{" "} + overage {formatOverageRates(plan.overageRates, plan.currency)}
        +
        - {/* Right column — price + edit */} -
        -
        -
        - {formatMoney(plan.monthlyBasePrice, plan.currency)} -
        -
        - per month -
        + {/* Right column — price + edit */} +
        +
        +
        + {formatMoney(plan.monthlyBasePrice, plan.currency)} +
        +
        + per month
        -
        -
      • - ))} -
      - )} - - + +
      +
    • + ))} +
    + )} +
    ); } diff --git a/clients/admin/src/pages/dashboard.tsx b/clients/admin/src/pages/dashboard.tsx index 0736fb3f6a..a1449d08a4 100644 --- a/clients/admin/src/pages/dashboard.tsx +++ b/clients/admin/src/pages/dashboard.tsx @@ -1,24 +1,24 @@ import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { ArrowUpRight, Building2, FileText, Receipt, UsersRound } from "lucide-react"; +import { + ArrowRight, + Building2, + FileText, + LayoutDashboard, + Receipt, + UsersRound, +} from "lucide-react"; import { listTenants } from "@/api/tenants"; import { listInvoices, getPlans } from "@/api/billing"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { KpiTile } from "@/components/kpi-tile"; import { Skeleton } from "@/components/ui/skeleton"; +import { EntityPageHeader, Stat, StatStrip, ToneIconTile, type ToneIconTileTone } from "@/components/list"; import { useAuth } from "@/auth/use-auth"; +import { cn } from "@/lib/cn"; /** - * DashboardPage — the Console "overview." A live system status header, four - * KPI tiles drawing from real data, then quick-pivot cards into the rest of - * the app. No fake "Coming soon" filler; every panel is either real data - * or removed. + * DashboardPage — the operator overview. EntityPageHeader greeting, + * four KPI stat tiles drawing from real data, then pivot cards into + * the rest of the app. No fake "Coming soon" filler. */ export function DashboardPage() { const { user } = useAuth(); @@ -43,37 +43,29 @@ export function DashboardPage() { const outstandingCount = invoicesPage?.items.filter((i) => i.status === "Issued").length ?? 0; + const firstName = user?.name?.split(" ")[0]; + return ( -
    - {/* ── Hero header ──────────────────────────────────────────────── */} -
    -
    - // OVERVIEW - - platform status - - - - live - -
    -
    -
    -

    - Console{user?.name ? , {user.name.split(" ")[0]} : null} - . -

    -

    - Operate every tenant on this instance — identity, multitenancy, billing, - and the rest of the system surface, from one place. -

    -
    -
    -
    +
    + {/* ── Page header ──────────────────────────────────────────────── */} +
    + + Overview{firstName ? ( + , {firstName} + ) : null} + + } + tone="primary" + description="Operate every tenant on this instance — identity, multitenancy, billing, and the rest of the system surface." + /> +
    - {/* ── KPI strip ────────────────────────────────────────────────── */} -
    - + - - @@ -104,13 +96,13 @@ export function DashboardPage() { invoicesPage?.items.length.toLocaleString() ?? "—" ) } - subtitle={ + hint={ invoicesPage ? `${invoicesPage.totalCount.toLocaleString()} total ledger` : "loading…" } /> - 0 ? "warning" : "default"} /> -
    + {/* ── Quick pivots ─────────────────────────────────────────────── */}
    -
    // ENTRY POINTS
    -
    +

    + Entry points +

    +
    @@ -162,33 +161,40 @@ export function DashboardPage() { function PivotCard({ to, icon: Icon, + tone, title, description, }: { to: string; icon: typeof Building2; + tone: ToneIconTileTone; title: string; description: string; }) { return ( - - -
    - - - - {title} +
    +
    + + +
    +
    +
    + {title}
    - - - - {description} - - +

    + {description} +

    +
    +
    ); } diff --git a/clients/admin/src/pages/health/page.tsx b/clients/admin/src/pages/health/page.tsx index 06f90f6fb7..1927fb34d1 100644 --- a/clients/admin/src/pages/health/page.tsx +++ b/clients/admin/src/pages/health/page.tsx @@ -1,9 +1,9 @@ import { useQuery } from "@tanstack/react-query"; -import { Activity, ChevronRight, RefreshCw } from "lucide-react"; +import { Activity, ChevronRight, Heart, RefreshCw } from "lucide-react"; import { getLiveness, getReadiness, type HealthEntry, type HealthResult, type HealthStatus } from "@/api/health"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { PageHeader, ErrorBand, StatStrip, Stat } from "@/components/list"; +import { EntityPageHeader, ErrorBand, SettingsSection, StatStrip, Stat } from "@/components/list"; import { cn } from "@/lib/cn"; const REFRESH_INTERVAL_MS = 10_000; @@ -43,9 +43,9 @@ export function HealthPage() { return (
    - @@ -55,13 +55,12 @@ export function HealthPage() { scraped by load balancers and uptime monitors. } - actions={ - - } - /> + > + + -
    -
    -
    -

    {title}

    - {result && } -
    -

    - {description} -

    + + {result && } + {path}
    - {path} -
    - + } + > {loading ? ( -
    +
    Probing
    ) : !result || result.results.length === 0 ? ( -
    +
    No dependency checks reported.
    @@ -171,13 +166,13 @@ function ProbeSection({
    ) : ( -
      +
        {result.results.map((entry) => ( ))}
      )} - + ); } diff --git a/clients/admin/src/pages/impersonation/list.tsx b/clients/admin/src/pages/impersonation/list.tsx index 7074496a9c..7be25b83e3 100644 --- a/clients/admin/src/pages/impersonation/list.tsx +++ b/clients/admin/src/pages/impersonation/list.tsx @@ -11,7 +11,7 @@ import { useAuth } from "@/auth/use-auth"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - PageHeader, + EntityPageHeader, ErrorBand, LoadingRow, StatStrip, @@ -74,23 +74,23 @@ export function ImpersonationListPage() { return (
      - grants.refetch()} - > - - Refresh - - } - /> + > + + 0 ? "signal" : "default"} /> @@ -322,7 +322,7 @@ function Details({ grant }: { grant: ImpersonationGrantDto }) { function DRow({ label, children, wide }: { label: string; children: React.ReactNode; wide?: boolean }) { return (
      -
      {label}
      +
      {label}
      {children}
      ); diff --git a/clients/admin/src/pages/login.tsx b/clients/admin/src/pages/login.tsx index 4fabccefc9..2dcb605a6f 100644 --- a/clients/admin/src/pages/login.tsx +++ b/clients/admin/src/pages/login.tsx @@ -1,6 +1,16 @@ import { useState, type FormEvent } from "react"; import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"; -import { ClipboardCheck, Copy, FlaskConical } from "lucide-react"; +import { + AlertCircle, + ArrowRight, + ClipboardCheck, + Copy, + Eye, + EyeOff, + FlaskConical, + Loader2, + ShieldCheck, +} from "lucide-react"; import { useAuth } from "@/auth/use-auth"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,12 +30,6 @@ const DEMO_SUPERADMIN = { persona: "Platform operator · cross-tenant control", } as const; -/** - * LoginPage — editorial split-screen. Left pane (hidden below lg) is the - * brand stage: 32px coordinate-grid mesh + ASCII-style monogram + a soft - * chartreuse vignette anchored top-left. Right pane is the form, which - * gets the same hairline-everything treatment as the rest of Console. - */ export function LoginPage() { const { isAuthenticated, login } = useAuth(); const navigate = useNavigate(); @@ -38,6 +42,7 @@ export function LoginPage() { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + const [showPassword, setShowPassword] = useState(false); if (isAuthenticated) { return ; @@ -81,197 +86,189 @@ export function LoginPage() { }; return ( -
      - {/* ─── Left pane — brand stage ───────────────────────────────── */} - - {/* ─── Right pane — form ─────────────────────────────────────── */} -
      - {/* Subtle subgrid on the form pane too */} -
      +
      + + setEmail(e.target.value)} + autoComplete="email" + placeholder="operator@root.example" + required + aria-invalid={error ? true : undefined} + className="h-11 text-[14px]" + /> +
      -
      - {/* Mobile-only brand (lg+ uses the left pane) */} -
      - -
      +
      +
      + + + Forgot? + +
      +
      + setPassword(e.target.value)} + autoComplete="current-password" + placeholder="Enter your password" + required + aria-invalid={error ? true : undefined} + className="h-11 pr-11 text-[14px]" + /> + +
      +
      - {/* Section rule + form header */} -
      -
      - // AUTHENTICATE - - request a session - -
      -

      - Use a root-tenant operator account. Tenant administrators sign in through their own tenant. -

      -
      + {error && ( + + )} - - - - +
      + +
      + - {error && ( -
      - {error} + {import.meta.env.DEV && ( +
      +
      )} +
      +
      - - -
      - - // forgot password? - -
      - - - {import.meta.env.DEV && ( - - )} +
      + + Encrypted in transit · JWT-secured session
      -
      +

      + FullStackHero Administration +

      +
      ); } // ─── subcomponents ─────────────────────────────────────────────────── -function CornerTicks() { - // L-shaped tick marks at each corner, in the chartreuse signal. - // Reads as "this surface has coordinates" without being a literal crosshair. - const TICK = "h-3 w-3 border-[var(--color-accent-signal)]"; - return ( - <> - - - - - - ); -} - -type FieldProps = { - id: string; - label: string; - value: string; - onChange: (v: string) => void; - type?: string; - required?: boolean; - placeholder?: string; - autoComplete?: string; -}; - -function Field({ - id, - label, - value, - onChange, - type, - required, - placeholder, - autoComplete, -}: FieldProps) { - return ( -
      - - onChange(e.target.value)} - required={required} - placeholder={placeholder} - autoComplete={autoComplete} - /> -
      - ); -} - function DevDemoCallout({ active, copied, @@ -287,28 +284,28 @@ function DevDemoCallout({
      -
      - DEV · demo account +
      + Dev · demo account
      + + {/* Primary actions */} +
      + +
      + + {/* Tertiary — go-back */} +
      ); diff --git a/clients/admin/src/pages/notifications/inbox.tsx b/clients/admin/src/pages/notifications/inbox.tsx index f6233bf2a3..c3f1d30e49 100644 --- a/clients/admin/src/pages/notifications/inbox.tsx +++ b/clients/admin/src/pages/notifications/inbox.tsx @@ -12,10 +12,10 @@ import { useRealtimeEvent } from "@/realtime/realtime-context"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { + EntityPageHeader, ErrorBand, FilterBar, LoadingRow, - PageHeader, Select, } from "@/components/list"; import { EmptyState } from "@/components/empty-state"; @@ -64,34 +64,34 @@ export function NotificationsInboxPage() { return (
      - - - - - } - /> + > + + + - - - + + + + - - - - - - - - + label="Description" + hint="Optional. Plain English explaining what this role is for." + error={errors.description?.message} + > + + +
      + +
      ); diff --git a/clients/admin/src/pages/roles/detail.tsx b/clients/admin/src/pages/roles/detail.tsx index c796e0c2fa..9879289f10 100644 --- a/clients/admin/src/pages/roles/detail.tsx +++ b/clients/admin/src/pages/roles/detail.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ArrowLeft, ShieldCheck, Trash2 } from "lucide-react"; +import { ArrowLeft, Lock, Shield, ShieldCheck, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { deleteRole, @@ -17,13 +17,11 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { - PageHeader, + EntityPageHeader, ErrorBand, Field, LoadingRow, - FormShell, - FormSection, - FormActions, + SettingsSection, } from "@/components/list"; import { PERMISSION_CATALOG, @@ -55,38 +53,30 @@ export function RoleDetailPage() { const isSystem = role ? SYSTEM_ROLE_NAMES.has(role.name) : false; return ( -
      - - {isSystem && ( - - system - - )} - - {(role.permissions?.length ?? 0).toString().padStart(2, "0")} grants - - - ) : ( - "—" - ) - } +
      + navigate("/roles")}> - Registry - - } - /> + > + {isSystem && ( + + System + + )} + + {query.isError && ( } + {isSystem && role && ( +
      + + + +
      +

      + Built-in role — read only +

      +

      + {role.name} ships with the framework. + Its name, description, and permissions are managed centrally. Create a custom role if + you need a different set of grants. +

      +
      +
      + )} + {role && ( <> - + {!isSystem && ( mutation.mutate(v))}> - - - Name and description shown to operators when assigning users to this role. - {disabled && ( - - System roles cannot be renamed. - - )} - - } - > + + + +
      + } + > +
      - - - - - - - +
      + ); } @@ -270,89 +287,148 @@ function PermissionEditor({ role, disabled }: { role: RoleDto; disabled: boolean }; return ( - - - Pick what holders of this role can do. Root-only permissions are visually - marked — they take effect only on roles assigned in the root tenant. - - {String(selected.size).padStart(2, "0")} of {String(total).padStart(2, "0")} granted - - - } - > -
      + +
      + {dirty ? ( + + + Unsaved changes · {String(selected.size).padStart(2, "0")} of{" "} + {String(total).padStart(2, "0")} granted + + ) : ( + + All changes saved · {String(selected.size).padStart(2, "0")} of{" "} + {String(total).padStart(2, "0")} granted + + )} +
      +
      + + +
      +
      + ) : undefined + } + > +
      {PERMISSION_CATALOG.map((group) => { const groupCount = group.entries.filter((e) => selected.has(e.name)).length; const allOn = groupCount === group.entries.length; const someOn = groupCount > 0 && groupCount < group.entries.length; return ( -
      -
      +
      +
      -

      +

      {group.category}

      - + {String(groupCount).padStart(2, "0")} / {String(group.entries.length).padStart(2, "0")}
      -

      +

      {group.blurb}

      - +
      -
        +
          {group.entries.map((entry) => { const checked = selected.has(entry.name); return ( -
        • +
        • +
        • +
        • + ); +} + +// ─── Desktop row ──────────────────────────────────────────────────────── - +function RoleDesktopRow({ + role, + isLast, + onClick, +}: { + role: RoleDto; + isLast: boolean; + onClick: () => void; +}) { + const isSystem = ROOT_ROLE_NAMES.has(role.name); + const permLabel = + role.permissions === undefined || role.permissions === null + ? "—" + : `${role.permissions.length} ${role.permissions.length === 1 ? "permission" : "permissions"}`; + + return ( +
        • +
        • ); diff --git a/clients/admin/src/pages/settings/appearance.tsx b/clients/admin/src/pages/settings/appearance.tsx index 50856f0bb3..5496869562 100644 --- a/clients/admin/src/pages/settings/appearance.tsx +++ b/clients/admin/src/pages/settings/appearance.tsx @@ -1,18 +1,33 @@ -import { Moon, Sun } from "lucide-react"; +import { Moon, Palette, Sun } from "lucide-react"; import { useTheme } from "@/components/theme/theme-provider"; import { Button } from "@/components/ui/button"; -import { FormSection, FormShell } from "@/components/list"; +import { SettingsSection } from "@/components/list"; import { cn } from "@/lib/cn"; type Mode = "light" | "dark"; -const MODES: { value: Mode; label: string; icon: typeof Sun; blurb: string }[] = [ - { value: "light", label: "Light", icon: Sun, blurb: "Paper-white surfaces, magazine-print mood." }, - { value: "dark", label: "Dark", icon: Moon, blurb: "Console-default. Lower glare for long sessions." }, +const MODES: { + value: Mode; + label: string; + icon: typeof Sun; + blurb: string; +}[] = [ + { + value: "light", + label: "Light", + icon: Sun, + blurb: "Paper-white surfaces, magazine-print mood.", + }, + { + value: "dark", + label: "Dark", + icon: Moon, + blurb: "Console-default. Lower glare for long sessions.", + }, ]; /** - * AppearanceSettings — theme picker. ThemeProvider only carries a binary + * AppearanceSettings — theme picker. ThemeProvider carries a binary * light/dark today; a future "Follow system" mode would extend the provider * to a tri-state. Persistence is handled by the provider; we just call setTheme. */ @@ -20,9 +35,11 @@ export function AppearanceSettings() { const { theme, setTheme } = useTheme(); return ( - - + {/* Theme */} +
          @@ -35,31 +52,58 @@ export function AppearanceSettings() { onClick={() => setTheme(value)} aria-pressed={active} className={cn( - "group/card flex flex-col items-start gap-2 rounded-md border px-4 py-3 text-left transition-colors", + "group/card relative overflow-hidden flex flex-col items-start gap-2 rounded-xl border p-4 text-left", + "transition-colors duration-[var(--duration-default)]", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2", active ? "border-[var(--color-accent-signal)] bg-[oklch(from_var(--color-accent-signal)_l_c_h_/_0.08)]" - : "border-[var(--color-border)] hover:bg-[var(--color-muted)]/60", + : "border-[var(--color-border)] bg-[var(--color-card)] hover:bg-[var(--color-muted)]", )} > - - +
          + + + + {active && ( + + Active + + )} +
          + + {label} + + + {blurb} - {label} - {blurb} ); })}
          -
          + - - -
          + +
      ); } diff --git a/clients/admin/src/pages/settings/layout.tsx b/clients/admin/src/pages/settings/layout.tsx index fba40547b7..19225c54e0 100644 --- a/clients/admin/src/pages/settings/layout.tsx +++ b/clients/admin/src/pages/settings/layout.tsx @@ -1,6 +1,6 @@ import { NavLink, Outlet } from "react-router-dom"; -import { MonitorSmartphone, Palette, ShieldCheck, UserRound } from "lucide-react"; -import { PageHeader } from "@/components/list"; +import { MonitorSmartphone, Palette, Settings, ShieldCheck, UserRound } from "lucide-react"; +import { EntityPageHeader } from "@/components/list"; import { cn } from "@/lib/cn"; type Tab = { @@ -17,15 +17,15 @@ const TABS: Tab[] = [ ]; /** - * SettingsLayout — header + pill tab nav + outlet for the active tab. - * Mirrors dashboard's settings shell but in Console aesthetic: mono-caps - * labels in the active pill, hairline borders, no brand-soft fills. + * SettingsLayout — EntityPageHeader + underline tab nav + outlet. + * Console aesthetic: underline indicator with chartreuse accent signal, + * mono-caps icon labels, hairline rule — no sidebar, no brand-soft pills. */ export function SettingsLayout() { return (
      - diff --git a/clients/admin/src/pages/settings/profile.tsx b/clients/admin/src/pages/settings/profile.tsx index b327de88bf..dbfc2fc656 100644 --- a/clients/admin/src/pages/settings/profile.tsx +++ b/clients/admin/src/pages/settings/profile.tsx @@ -1,39 +1,49 @@ -import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Loader2, Trash2, Upload } from "lucide-react"; +import { Fingerprint, ShieldCheck, UserRound } from "lucide-react"; import { toast } from "sonner"; import { getMyProfile, setProfileImage } from "@/api/users"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { - ErrorBand, - Field, - FormShell, - FormSection, - LoadingRow, -} from "@/components/list"; -import { Monogram } from "@/components/monogram"; +import { ErrorBand, LoadingRow, SettingsSection, SettingsField } from "@/components/list"; +import { ImageInput } from "@/components/file/image-input"; import { ApiRequestError } from "@/lib/api-client"; /** * ProfileSettings — read-only view of identity fields (server doesn't expose - * an /update-me endpoint for these yet) plus avatar upload via the existing - * /profile/image flow. Username, email, and name are intentionally not - * editable from here — they require admin involvement, which is correct for - * a multi-tenant operator console. + * an /update-me endpoint for these yet) plus avatar upload via the presigned + * ImageInput flow. Username, email, and name are intentionally not editable + * from here — they require admin involvement, which is correct for a + * multi-tenant operator console. + * + * Avatar fix: uses ImageInput + presigned upload (durable URL via Files module) + * instead of the old base64 data: URL approach that hit the 2048-char limit. */ export function ProfileSettings() { const queryClient = useQueryClient(); const profile = useQuery({ queryKey: ["identity", "profile"], queryFn: getMyProfile }); + const imageMutation = useMutation({ + mutationFn: (url: string | null) => setProfileImage(url), + onSuccess: () => { + toast.success("Profile image updated"); + void queryClient.invalidateQueries({ queryKey: ["identity", "profile"] }); + }, + onError: (err: unknown) => { + const message = + err instanceof ApiRequestError + ? (err.problem?.detail ?? err.problem?.title ?? err.message) + : "Failed to update profile image"; + toast.error(message); + }, + }); + if (profile.isLoading) return ; if (profile.isError) { return ( @@ -48,178 +58,98 @@ export function ProfileSettings() { "Account"; return ( - - + {/* Avatar — presigned upload via ImageInput, no base64 data: URLs */} + - queryClient.invalidateQueries({ queryKey: ["identity", "profile"] })} + imageMutation.mutate(next.length > 0 ? next : null)} + ownerType="User" + ownerId={user.id ?? null} + shape="circle" /> - + - - - - - - - - - - - - - -
      - +
      + + + + + + + + + {user.emailConfirmed !== undefined && ( +

      + {user.emailConfirmed ? "Address verified" : "Not yet verified"} +

      + )} +
      + + + +
      + + + {/* Status badges */} + +
      + {user.isActive ? "Active" : "Disabled"} - + {user.emailConfirmed ? "Email confirmed" : "Email pending"} - + {user.twoFactorEnabled ? "2FA enabled" : "2FA off"}
      - - - ); -} - -// ─── Avatar editor ────────────────────────────────────────────────────── - -const MAX_IMAGE_BYTES = 2 * 1024 * 1024; -const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp"]; - -function AvatarEditor({ - name, - userId, - imageUrl, - onUpdated, -}: { - name: string; - userId: string; - imageUrl: string | null; - onUpdated: () => void; -}) { - const fileRef = useRef(null); - const [preview, setPreview] = useState(null); - - useEffect(() => { - // The data: URL preview is short-lived; revoke the previous one when - // a new file is picked or the component unmounts. - return () => { - if (preview && preview.startsWith("blob:")) URL.revokeObjectURL(preview); - }; - }, [preview]); - - const mutation = useMutation({ - mutationFn: async (file: File | null) => { - if (file === null) { - await setProfileImage(null); - return null; - } - // The server's /profile/image endpoint takes a durable URL, not raw - // bytes — the Files module's presigned upload returns one. Here we - // skip that round-trip for v1 and inline a data: URL, which keeps the - // demo working without a storage bucket configured. Production - // deployments should switch to presigned uploads. - const dataUrl = await fileToDataUrl(file); - await setProfileImage(dataUrl); - return dataUrl; - }, - onSuccess: (newUrl) => { - toast.success(newUrl === null ? "Avatar removed" : "Avatar updated"); - setPreview(null); - onUpdated(); - }, - onError: (err: unknown) => { - const detail = - err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message - : (err as Error).message; - toast.error("Update failed", { description: detail }); - }, - }); - - const onPick = (file: File | null) => { - if (!file) return; - if (!ACCEPTED_TYPES.includes(file.type)) { - toast.error("Unsupported format", { description: "Use PNG, JPG, or WebP." }); - return; - } - if (file.size > MAX_IMAGE_BYTES) { - toast.error("Too large", { description: "Keep avatars under 2 MB." }); - return; - } - if (preview && preview.startsWith("blob:")) URL.revokeObjectURL(preview); - setPreview(URL.createObjectURL(file)); - mutation.mutate(file); - }; - - const currentSrc = preview ?? imageUrl ?? null; - - return ( -
      -
      - {currentSrc ? ( - // Avatar preview — object-cover so non-square images crop centrally. - Avatar - ) : ( - - )} -
      -
      - onPick(e.target.files?.[0] ?? null)} - /> - - {imageUrl && ( - - )} -
      +
      ); } -function fileToDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(reader.error ?? new Error("read failed")); - reader.readAsDataURL(file); - }); -} diff --git a/clients/admin/src/pages/settings/security.tsx b/clients/admin/src/pages/settings/security.tsx index f476c386dd..4ca3dd2960 100644 --- a/clients/admin/src/pages/settings/security.tsx +++ b/clients/admin/src/pages/settings/security.tsx @@ -3,7 +3,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ClipboardCheck, Copy, KeyRound, ShieldCheck, ShieldOff } from "lucide-react"; +import { + AlertCircle, + ClipboardCheck, + Copy, + KeyRound, + ShieldCheck, + ShieldOff, +} from "lucide-react"; import { toast } from "sonner"; import { changePassword, getMyProfile } from "@/api/users"; import { @@ -15,21 +22,29 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; import { ErrorBand, Field, - FormActions, - FormSection, - FormShell, LoadingRow, + SettingsSection, } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; /** * SecuritySettings — combines password change + 2FA enrollment/disable - * into one tab. Profile query fuels both: we read TwoFactorEnabled to + * into one tab. Profile query fuels both: we read twoFactorEnabled to * decide whether to render the enroll wizard or the disable controls. + * Password change is driven through a Dialog (mirrors dashboard pattern). */ export function SecuritySettings() { const profile = useQuery({ queryKey: ["identity", "profile"], queryFn: getMyProfile }); @@ -40,7 +55,7 @@ export function SecuritySettings() { @@ -50,14 +65,14 @@ export function SecuritySettings() { const twoFactorEnabled = profile.data?.twoFactorEnabled ?? false; return ( -
      +
      ); } -// ─── Password section ─────────────────────────────────────────────────── +// ─── Password section ──────────────────────────────────────────────────── const passwordSchema = z .object({ @@ -77,6 +92,38 @@ const passwordSchema = z type PasswordValues = z.infer; function PasswordSection() { + const [dialogOpen, setDialogOpen] = useState(false); + + return ( + <> + +
      +

      + Changing your password does not revoke other sessions automatically — visit the Sessions + tab to sign out other devices. +

      + +
      +
      + + + + ); +} + +function ChangePasswordDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (next: boolean) => void; +}) { const { register, handleSubmit, @@ -87,6 +134,11 @@ function PasswordSection() { defaultValues: { current: "", next: "", confirm: "" }, }); + // Reset form every time the dialog opens so stale values don't bleed. + useEffect(() => { + if (open) reset({ current: "", next: "", confirm: "" }); + }, [open, reset]); + const mutation = useMutation({ mutationFn: (v: PasswordValues) => changePassword({ @@ -98,12 +150,12 @@ function PasswordSection() { toast.success("Password changed", { description: "Other active sessions remain valid until you revoke them.", }); - reset(); + onOpenChange(false); }, onError: (err) => { const detail = err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message + ? (err.problem?.detail ?? err.problem?.title ?? err.message) : (err as Error).message; toast.error("Change failed", { description: detail }); }, @@ -113,12 +165,17 @@ function PasswordSection() { const submitting = isSubmitting || mutation.isPending; return ( -
      - - + + + + Change password + + Sign-out events for other devices aren't fired automatically — visit the Sessions tab + below to end them after rotating your password. + + + + - + - + - - - - - -
      + + + + + + + + ); } -// ─── Two-factor section ───────────────────────────────────────────────── +// ─── Two-factor section ────────────────────────────────────────────────── function TwoFactorSection({ enabled }: { enabled: boolean }) { - if (enabled) { - return ; - } + if (enabled) return ; return ; } @@ -183,7 +259,7 @@ function TwoFactorEnroll() { onError: (err: unknown) => { const detail = err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message + ? (err.problem?.detail ?? err.problem?.title ?? err.message) : (err as Error).message; toast.error("Enrollment failed", { description: detail }); }, @@ -199,7 +275,7 @@ function TwoFactorEnroll() { setEnrollment(null); setCode(""); setQrSvg(null); - queryClient.invalidateQueries({ queryKey: ["identity", "profile"] }); + void queryClient.invalidateQueries({ queryKey: ["identity", "profile"] }); } else { toast.error("Verification failed", { description: "That code didn't match. Try again." }); } @@ -207,7 +283,7 @@ function TwoFactorEnroll() { onError: (err: unknown) => { const detail = err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message + ? (err.problem?.detail ?? err.problem?.title ?? err.message) : (err as Error).message; toast.error("Verification failed", { description: detail }); }, @@ -221,7 +297,7 @@ function TwoFactorEnroll() { return; } let cancelled = false; - // Lazy-load the ~50KB qrcode lib only when 2FA enrollment actually starts. + // Lazy-load the ~50 KB qrcode lib only when 2FA enrollment actually starts. import("qrcode") .then(({ default: QRCode }) => QRCode.toString(enrollment.authenticatorUri, { @@ -234,7 +310,6 @@ function TwoFactorEnroll() { .then((svg) => { if (cancelled) return; // Strip the default white background so the QR adapts to dark mode. - // currentColor + black-on-transparent reads well on both surfaces. const themed = svg .replace(/fill="#ffffff"/gi, 'fill="transparent"') .replace(/fill="#FFFFFF"/gi, 'fill="transparent"') @@ -255,116 +330,124 @@ function TwoFactorEnroll() { setCopiedKey(true); window.setTimeout(() => setCopiedKey(false), 1500); } catch { - /* noop */ + /* clipboard unavailable — silently noop */ } }; return ( - - - Adds an authenticator app code on top of your password. Recommended for every - operator account. - - off - - - } - > - {!enrollment ? ( -
      - - - You'll scan a QR code in your authenticator app (1Password, Google Authenticator, Authy…). - -
      - ) : ( -
      -
      -
      - {qrSvg ? ( -
      - ) : ( - - Rendering - - )} -
      -
      -
      -
      - can't scan? enter manually -
      -
      - {enrollment.sharedKey} - -
      + + Adds an authenticator app code on top of your password. Recommended for every operator + account. + + off + + + } + > + {!enrollment ? ( +
      + + + You'll scan a QR code in your authenticator app (1Password, Google Authenticator, Authy…). + +
      + ) : ( +
      +
      +
      + {qrSvg ? ( +
      + ) : ( + + Rendering… + + )} +
      +
      +
      +
      + Can't scan? Enter manually
      - - - setCode(e.target.value.replace(/\s/g, ""))} - className={cn("font-mono text-lg tracking-[0.4em]", code.length >= 6 && "border-[var(--color-accent-signal)]")} - /> - - -
      - - + {copiedKey ? ( + <> + copied + + ) : ( + <> + copy + + )} +
      + +
      + + setCode(e.target.value.replace(/\s/g, ""))} + className={cn( + "font-mono text-lg tracking-[0.4em]", + code.length >= 6 && "border-[var(--color-accent-signal)]", + )} + /> +
      + +
      + + +
      - )} - - +
      + )} + ); } @@ -378,7 +461,7 @@ function TwoFactorDisable() { if (data.success) { toast.success("Two-factor disabled"); setPassword(""); - queryClient.invalidateQueries({ queryKey: ["identity", "profile"] }); + void queryClient.invalidateQueries({ queryKey: ["identity", "profile"] }); } else { toast.error("Disable failed", { description: "Password verification failed." }); } @@ -386,27 +469,42 @@ function TwoFactorDisable() { onError: (err: unknown) => { const detail = err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message + ? (err.problem?.detail ?? err.problem?.title ?? err.message) : (err as Error).message; toast.error("Disable failed", { description: detail }); }, }); return ( - - - Two-factor is currently enabled on your account. Confirm your password to disable — - this rotates the authenticator secret, so a fresh enroll will generate a new QR. - - enabled - - - } - > - + + Two-factor is currently enabled on your account. Confirm your password to disable — this + rotates the authenticator secret, so a fresh enroll will generate a new QR. + + enabled + + + } + footer={ +
      + +
      + } + > +
      +
      + setPassword(e.target.value)} className="font-mono" /> - - - -
      + {/* Inline action for the sm breakpoint — the footer handles the + final CTA at all widths but this grid-col-auto slot keeps + things tidy on desktop. */} +
      + +
      +
      + + {mutation.isError && ( +
      - - {mutation.isPending ? "Disabling…" : "Disable two-factor"} - - - + + + {mutation.error instanceof ApiRequestError + ? (mutation.error.problem?.detail ?? mutation.error.message) + : (mutation.error as Error).message} + +
      + )} +
      ); } diff --git a/clients/admin/src/pages/settings/sessions.tsx b/clients/admin/src/pages/settings/sessions.tsx index e8c6b78403..b99c1fb02a 100644 --- a/clients/admin/src/pages/settings/sessions.tsx +++ b/clients/admin/src/pages/settings/sessions.tsx @@ -5,6 +5,7 @@ import { LogOut, Monitor, MoreHorizontal, + MonitorSmartphone, Smartphone, } from "lucide-react"; import { toast } from "sonner"; @@ -16,12 +17,7 @@ import { } from "@/api/sessions"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { - ErrorBand, - FormSection, - FormShell, - LoadingRow, -} from "@/components/list"; +import { ErrorBand, LoadingRow, SettingsSection } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; @@ -44,7 +40,7 @@ export function SessionsSettings() { onMutate: (sessionId) => setBusyIds((prev) => new Set(prev).add(sessionId)), onSuccess: () => { toast.success("Session revoked"); - queryClient.invalidateQueries({ queryKey: ["identity", "sessions", "me"] }); + void queryClient.invalidateQueries({ queryKey: ["identity", "sessions", "me"] }); }, onError: (err) => toast.error("Revoke failed", { description: describe(err) }), onSettled: (_d, _e, sessionId) => @@ -58,8 +54,10 @@ export function SessionsSettings() { const revokeAll = useMutation({ mutationFn: revokeAllMySessions, onSuccess: (data) => { - toast.success(`Revoked ${data.revokedCount} other ${data.revokedCount === 1 ? "session" : "sessions"}`); - queryClient.invalidateQueries({ queryKey: ["identity", "sessions", "me"] }); + toast.success( + `Revoked ${data.revokedCount} other ${data.revokedCount === 1 ? "session" : "sessions"}`, + ); + void queryClient.invalidateQueries({ queryKey: ["identity", "sessions", "me"] }); }, onError: (err) => toast.error("Revoke all failed", { description: describe(err) }), }); @@ -70,7 +68,7 @@ export function SessionsSettings() { @@ -78,17 +76,44 @@ export function SessionsSettings() { } return ( - - + 0 ? ( +
      +
      + + + {activeOtherCount} other{" "} + {activeOtherCount === 1 ? "session is" : "sessions are"} active. + Sign them all out at once if you suspect an account compromise. + +
      + +
      + ) : undefined + } > {sorted.length === 0 ? (

      No active sessions found. (Including this one? That would be a bug — please refresh.)

      ) : ( -
        +
          {sorted.map((s) => ( )} - - {activeOtherCount > 0 && ( -
          -
          - - - {activeOtherCount} other {activeOtherCount === 1 ? "session is" : "sessions are"} active. - Sign them all out at once if you suspect an account compromise. - -
          - -
          - )} - - + +
      ); } @@ -134,15 +138,24 @@ function SessionRow({ busy: boolean; onRevoke: () => void; }) { - const Icon = (session.deviceType ?? "").toLowerCase().includes("mobile") ? Smartphone : Monitor; + const isMobile = (session.deviceType ?? "").toLowerCase().includes("mobile"); + const Icon = isMobile ? Smartphone : Monitor; + return (
    • - +
      @@ -166,8 +179,8 @@ function SessionRow({
    • {session.isCurrentSession ? ( - - use Sign out + + use Sign out ) : session.isActive ? ( - } - /> + > + + -
      - - + + +
      - +
      +
      - - - - - + + + + + - + - - - - + placeholder="Host=…;Database=…" + className="font-mono" + aria-invalid={errors.connectionString ? true : undefined} + {...register("connectionString")} + /> + + - - - - -
      +
      + + +
      ); diff --git a/clients/admin/src/pages/tenants/detail.tsx b/clients/admin/src/pages/tenants/detail.tsx index 5f529434fb..fd8b1f05ec 100644 --- a/clients/admin/src/pages/tenants/detail.tsx +++ b/clients/admin/src/pages/tenants/detail.tsx @@ -1,7 +1,18 @@ import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, CheckCircle2, CircleDashed, Loader2, RefreshCw, UserCog, XCircle } from "lucide-react"; +import { + ArrowLeft, + Building2, + CheckCircle2, + CircleDashed, + ClipboardList, + Info, + Loader2, + RefreshCw, + UserCog, + XCircle, +} from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/auth/use-auth"; import { ImpersonateDialog } from "@/components/impersonation/impersonate-dialog"; @@ -19,11 +30,10 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Monogram } from "@/components/monogram"; import { - PageHeader, + EntityPageHeader, ErrorBand, LoadingRow, - FormShell, - FormSection, + SettingsSection, } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; @@ -48,8 +58,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) — 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,23 +95,22 @@ export function TenantDetailPage() { const tenant = tenantQuery.data; const provisioning = provisioningQuery.data; + const provisioningNotTracked = + provisioningQuery.error instanceof ApiRequestError && + provisioningQuery.error.status === 404; return (
      - navigate("/tenants")}> - Registry - - } - /> + > + + {tenantQuery.isError && ( @@ -103,59 +120,65 @@ export function TenantDetailPage() { {tenant && ( <> -
      -
      - -
      -

      - {tenant.name} -

      -
      - {tenant.id} - {tenant.adminEmail} -
      -
      - - {tenant.isActive ? "Active" : "Inactive"} - - - valid · {formatDate(tenant.validUpto)} - - {tenant.issuer && ( + {/* Hero identity card */} + +
      +
      + +
      +

      + {tenant.name} +

      +
      + {tenant.id} + {tenant.adminEmail} +
      +
      + + {tenant.isActive ? "Active" : "Inactive"} + - iss · {tenant.issuer} + valid · {formatDate(tenant.validUpto)} - )} + {tenant.issuer && ( + + iss · {tenant.issuer} + + )} +
      -
      -
      - {canImpersonate && tenant.isActive && ( +
      + {canImpersonate && tenant.isActive && ( + + )} - )} - +
      -
      + - - -
      - {tenant.id} - {tenant.name} - {tenant.adminEmail} - {tenant.issuer ?? "—"} - {formatDate(tenant.validUpto)} - {tenant.isActive ? "Active" : "Inactive"} -
      -
      -
      + {/* Details section */} + +
      + {tenant.id} + {tenant.name} + {tenant.adminEmail} + {tenant.issuer ?? "—"} + {formatDate(tenant.validUpto)} + {tenant.isActive ? "Active" : "Inactive"} +
      +
      - - - Live status of the background pipeline that seeds the tenant database, - default roles, and admin user. Polls every 2 seconds while running. - - } - > - retryMutation.mutate()} - retryPending={retryMutation.isPending} - /> - - + {/* Provisioning section */} + + retryMutation.mutate()} + retryPending={retryMutation.isPending} + /> + )}
      @@ -240,6 +261,7 @@ function ProvisioningPanel({ errorBody, loading, error, + notTracked = false, onRetry, retryPending, }: { @@ -249,10 +271,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" @@ -283,8 +306,11 @@ function ProvisioningPanel({ {error ? ( ) : loading && steps.length === 0 ? ( -

      - Loading +

      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 ? (

      @@ -351,11 +377,6 @@ function StepRow({ step, index }: { step: TenantProvisioningStep; index: number // ─── helpers ──────────────────────────────────────────────────────────── -function shortId(id: string): string { - if (id.length <= 12) return id; - return `${id.slice(0, 4)}…${id.slice(-4)}`; -} - function formatDate(value: string | undefined): string { if (!value) return "—"; const d = new Date(value); diff --git a/clients/admin/src/pages/tenants/list.tsx b/clients/admin/src/pages/tenants/list.tsx index 10393a699a..19f2a4aa63 100644 --- a/clients/admin/src/pages/tenants/list.tsx +++ b/clients/admin/src/pages/tenants/list.tsx @@ -1,16 +1,19 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { ChevronLeft, ChevronRight, Plus } from "lucide-react"; +import { Building2, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { listTenants, type TenantDto } from "@/api/tenants"; import { Button } from "@/components/ui/button"; import { Monogram } from "@/components/monogram"; -import { SectionRule } from "@/components/section-rule"; +import { EntityPageHeader, ErrorBand } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; const PAGE_SIZE = 12; +// Desktop grid template — shared by header + rows. +const DESKTOP_COLS = "grid-cols-[1fr_140px_24px] lg:grid-cols-[1.6fr_1.4fr_140px_24px]"; + function formatDate(value: string): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString(); @@ -28,7 +31,6 @@ export function TenantsListPage() { const data = query.data; const items: TenantDto[] = data?.items ?? []; - const baseIndex = ((data?.pageNumber ?? 1) - 1) * (data?.pageSize ?? PAGE_SIZE); const pageBadge = useMemo(() => { if (!data) return "—"; @@ -38,70 +40,101 @@ export function TenantsListPage() { }, [data]); return ( -

      - +
      + + + -
      -
      -

      - Registry -

      -

      - {data - ? `${data.totalCount} ${data.totalCount === 1 ? "tenant" : "tenants"} registered on this instance.` - : "Loading the registry…"} + {query.isError && ( + + )} + + {query.isLoading && items.length === 0 && ( +

      + Loading… +
      + )} + + {!query.isLoading && items.length === 0 && !query.isError && ( +
      +

      No tenants yet.

      +

      + Provision the first tenant to get started.

      - -
      - - {/* Roster */} -
      - {query.isError && ( -
      - {query.error instanceof ApiRequestError - ? query.error.problem?.detail ?? query.error.message - : "Failed to load tenants."} -
      - )} + )} - {query.isLoading && items.length === 0 && ( -
      - Loading… -
      - )} + {items.length > 0 && ( +
      +

      + {data?.totalCount ?? 0} tenant{(data?.totalCount ?? 0) !== 1 ? "s" : ""} registered +

      - {!query.isLoading && items.length === 0 && ( -
      -

      No tenants yet.

      -

      - Provision the first tenant to get started. -

      + {/* Mobile card list */} +
      + {items.map((tenant, i) => ( + navigate(`/tenants/${tenant.id}`)} + /> + ))}
      - )} -
        - {items.map((tenant, i) => ( - navigate(`/tenants/${tenant.id}`)} - /> - ))} -
      -
      + {/* Desktop table */} +
      +
      + + Tenant + + + Admin email + + + Status + + +
      + +
        + {items.map((tenant, i) => ( + navigate(`/tenants/${tenant.id}`)} + /> + ))} +
      +
      +
      + )} {/* Pagination */} {data && data.totalPages > 1 && ( @@ -115,6 +148,7 @@ export function TenantsListPage() { size="sm" disabled={!data.hasPrevious || query.isFetching} onClick={() => setPageNumber((p) => Math.max(1, p - 1))} + className="h-9 rounded-lg px-3 text-[13px]" > Previous @@ -123,6 +157,7 @@ export function TenantsListPage() { size="sm" disabled={!data.hasNext || query.isFetching} onClick={() => setPageNumber((p) => p + 1)} + className="h-9 rounded-lg px-3 text-[13px]" > Next @@ -133,63 +168,104 @@ export function TenantsListPage() { ); } -function TenantRow({ - tenant, - index, - onClick, -}: { - tenant: TenantDto; - index: number; - onClick: () => void; -}) { - const num = String(index).padStart(3, "0"); +// ─── Status pill ───────────────────────────────────────────────────────── + +function StatusPill({ active }: { active: boolean }) { return ( -
    • + + {active ? "Active" : "Inactive"} + + ); +} + +// ─── Mobile card ─────────────────────────────────────────────────────────── + +function TenantMobileCard({ tenant, onClick }: { tenant: TenantDto; onClick: () => void }) { + return ( +
    • ); } -function StatusDot({ active }: { active: boolean }) { +// ─── Desktop row ──────────────────────────────────────────────────────────── + +function TenantDesktopRow({ tenant, onClick }: { tenant: TenantDto; onClick: () => void }) { return ( - - + + ); } diff --git a/clients/admin/src/pages/users/create.tsx b/clients/admin/src/pages/users/create.tsx index 9494c4b5de..ce678e0dec 100644 --- a/clients/admin/src/pages/users/create.tsx +++ b/clients/admin/src/pages/users/create.tsx @@ -3,17 +3,15 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, KeyRound, User as UserIcon, Users } from "lucide-react"; import { toast } from "sonner"; import { registerUser } from "@/api/users"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { - PageHeader, + EntityPageHeader, Field, - FormShell, - FormSection, - FormActions, + SettingsSection, } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; @@ -93,130 +91,145 @@ export function CreateUserPage() { const submitting = isSubmitting || mutation.isPending; return ( -
      - + navigate("/users")}> - Directory - - } - /> + > + +
      - - + -
      - +
      +
      + + + + + + +
      + + - + + -
      - - - - - - - - - - - - + + + +
      +
      - + + +
      + } > - - - - - + + + + - - - - - - - - + label="Confirm password" + required + error={errors.confirmPassword?.message} + > + + +
      + +
      ); diff --git a/clients/admin/src/pages/users/detail.tsx b/clients/admin/src/pages/users/detail.tsx index 4029d88a57..fde0e92e71 100644 --- a/clients/admin/src/pages/users/detail.tsx +++ b/clients/admin/src/pages/users/detail.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Check, Mail, ShieldCheck } from "lucide-react"; +import { ArrowLeft, Check, Mail, ShieldCheck, User as UserIcon, Users } from "lucide-react"; import { toast } from "sonner"; import { assignUserRoles, @@ -15,12 +15,10 @@ import { Badge } from "@/components/ui/badge"; import { Monogram } from "@/components/monogram"; import { UserSessionsCard } from "@/components/sessions/user-sessions-card"; import { - PageHeader, + EntityPageHeader, ErrorBand, LoadingRow, - FormShell, - FormSection, - FormActions, + SettingsSection, } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; @@ -49,7 +47,7 @@ export function UserDetailPage() { queryClient.invalidateQueries({ queryKey: ["user", id] }); queryClient.invalidateQueries({ queryKey: ["users"] }); }, - onError: (err) => toast.error("Status change failed", { description: describe(err) }), + onError: (err) => toast.error("Status change failed", { description: describeErr(err) }), }); const user = userQuery.data; @@ -62,105 +60,125 @@ export function UserDetailPage() { id; return ( -
      - + navigate("/users")}> - Directory - - } - /> + description={user?.email ?? undefined} + > + + - {userQuery.isError && } + {userQuery.isError && } {userQuery.isLoading && !user && } {user && ( <> -
      -
      - -
      -

      - {displayName} -

      -
      - {user.userName && @{user.userName}} - {user.email && {user.email}} -
      -
      - - {user.isActive ? "Active" : "Disabled"} - - - - {user.emailConfirmed ? "Email confirmed" : "Email pending"} - + {/* Hero card */} +
      +
      +
      + +
      +

      + {displayName} +

      +
      + {user.userName && ( + + @{user.userName} + + )} + {user.email && {user.email}} +
      +
      + + {user.isActive ? "Active" : "Disabled"} + + + + {user.emailConfirmed ? "Email confirmed" : "Email pending"} + +
      -
      - -
      + +
      +
      - - + {/* Identity details */} + -
      - {user.id ?? "—"} - {user.userName ?? "—"} - {user.email ?? "—"} - {user.phoneNumber ?? "—"} - {user.isActive ? "Active" : "Disabled"} +
      + + {user.id ?? "—"} + + + {user.userName ?? "—"} + + + {user.email ?? "—"} + + + {user.phoneNumber ?? "—"} + + + {user.isActive ? "Active" : "Disabled"} + {user.emailConfirmed ? "Yes" : "Pending confirmation"}
      - - + - { - queryClient.invalidateQueries({ queryKey: ["user", id, "roles"] }); - queryClient.invalidateQueries({ queryKey: ["users"] }); - }} - /> + {/* Roles editor */} + { + queryClient.invalidateQueries({ queryKey: ["user", id, "roles"] }); + queryClient.invalidateQueries({ queryKey: ["users"] }); + }} + /> +
      @@ -181,9 +199,16 @@ function DetailRow({ mono?: boolean; }) { return ( -
      -
      {label}
      -
      +
      +
      + {label} +
      +
      {children}
      @@ -230,7 +255,7 @@ function RolesEditor({ queryClient.invalidateQueries({ queryKey: ["user", userId, "roles"] }); onSaved(); }, - onError: (err) => toast.error("Role update failed", { description: describe(err) }), + onError: (err) => toast.error("Role update failed", { description: describeErr(err) }), }); const onSave = () => { @@ -240,65 +265,66 @@ function RolesEditor({ const onDiscard = () => setDraft(original); return ( - - - Tap any role to toggle. Changes are batched — review and save when ready. - - {dirtyCount === 0 - ? "no pending changes" - : `${dirtyCount} ${dirtyCount === 1 ? "change" : "changes"} pending`} - - - } - > - {error ? ( - - ) : loading ? ( -

      - Loading -

      - ) : roles.length === 0 ? ( -

      - No roles defined for this tenant. -

      - ) : ( -
      - {roles.map((r) => ( - setDraft((d) => ({ ...d, [r.roleId]: !d[r.roleId] }))} - /> - ))} + 0 + ? `${dirtyCount} pending change${dirtyCount === 1 ? "" : "s"} — review and save when ready.` + : "Tap any role to toggle. Changes are batched — review and save when ready." + } + footer={ + !loading && roles.length > 0 ? ( +
      + +
      - )} - - - {!loading && roles.length > 0 && ( - - - - + ) : undefined + } + > + {error ? ( + + ) : loading ? ( +

      + Loading + +

      + ) : roles.length === 0 ? ( +

      + No roles defined for this tenant. +

      + ) : ( +
        + {roles.map((r) => ( + setDraft((d) => ({ ...d, [r.roleId]: !d[r.roleId] }))} + /> + ))} +
      )} - +
      ); } -function RoleChip({ +function RoleRow({ role, enabled, changed, @@ -308,38 +334,73 @@ function RoleChip({ enabled: boolean; changed: boolean; onToggle: () => void; +}) { + return ( +
    • +
      +
      + + {role.roleName ?? "Untitled role"} + + {changed && ( + + )} +
      + {role.description && ( +
      + {role.description} +
      + )} +
      + +
    • + ); +} + +function RoleChip({ + enabled, + changed, + onToggle, + label, +}: { + enabled: boolean; + changed: boolean; + onToggle: () => void; + label: string; }) { return ( ); } // ─── helpers ──────────────────────────────────────────────────────────── -function shortId(id: string): string { - if (id.length <= 12) return id; - return `${id.slice(0, 4)}…${id.slice(-4)}`; -} - -function describe(err: unknown): string { - if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message; +function describeErr(err: unknown): string { + if (err instanceof ApiRequestError) + return err.problem?.detail ?? err.problem?.title ?? err.message; if (err instanceof Error) return err.message; return String(err); } diff --git a/clients/admin/src/pages/users/list.tsx b/clients/admin/src/pages/users/list.tsx index 0c3620cd26..e4feeb5065 100644 --- a/clients/admin/src/pages/users/list.tsx +++ b/clients/admin/src/pages/users/list.tsx @@ -1,13 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { ChevronLeft, ChevronRight, Plus, Search } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Users } from "lucide-react"; import { searchUsers, type UserDto } from "@/api/users"; import { listRoles } from "@/api/roles"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Monogram } from "@/components/monogram"; -import { SectionRule } from "@/components/section-rule"; +import { EntityPageHeader, ErrorBand } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; @@ -21,6 +20,10 @@ function triToBool(v: Tri): boolean | undefined { return undefined; } +// Desktop grid template — shared by header + rows. +const DESKTOP_COLS = + "grid-cols-[1fr_140px_24px] lg:grid-cols-[1.6fr_140px_180px_24px]"; + export function UsersListPage() { const navigate = useNavigate(); @@ -79,39 +82,49 @@ export function UsersListPage() { return `Page ${p} of ${t}`; }, [data]); - return ( -
      - + const filtersActive = + activeFilter !== "any" || confirmedFilter !== "any" || roleId !== ""; + const searchActive = searchTerm.length > 0 || filtersActive; -
      -
      -

      - Directory -

      -

      - {data - ? `${data.totalCount} ${data.totalCount === 1 ? "account" : "accounts"} on this tenant.` - : "Loading the roster…"} -

      -
      - -
      + {/* Filter row */}
      - - + + + setSearchInput(e.target.value)} placeholder="Search name, username, email…" aria-label="Search users" - className="pl-9 font-mono text-xs placeholder:font-mono placeholder:text-xs" + className="h-9 w-full rounded-md border border-[var(--color-input)] bg-transparent pl-9 pr-3 font-mono text-[12.5px] outline-none transition-colors placeholder:text-[oklch(from_var(--color-muted-foreground)_l_c_h_/_0.7)] focus-visible:border-[var(--color-ring)] focus-visible:ring-[3px] focus-visible:ring-[oklch(from_var(--color-ring)_l_c_h_/_0.5)]" />
      @@ -141,7 +154,7 @@ export function UsersListPage() { setTenant(e.target.value)} - autoComplete="organization" - placeholder="root" - required - aria-invalid={error ? true : undefined} - className="h-11 text-[14px]" - /> -
      - -
      - - setEmail(e.target.value)} - autoComplete="email" - placeholder="operator@root.example" - required - aria-invalid={error ? true : undefined} - className="h-11 text-[14px]" - /> + {/* Form card */} +
      +
      +
      +

      + Welcome back +

      +

      + Sign in to your operator account +

      -
      -
      +
      + {/* Tenant */} +
      - - Forgot? - -
      -
      setPassword(e.target.value)} - autoComplete="current-password" - placeholder="Enter your password" + id="tenant" + value={tenant} + onChange={(e) => setTenant(e.target.value)} + autoComplete="organization" + placeholder="root" required aria-invalid={error ? true : undefined} - className="h-11 pr-11 text-[14px]" + className="h-11 text-[14px]" /> -
      -
      - {error && ( - -
      + {/* Password */} +
      +
      + + + Forgot? + +
      +
      + setPassword(e.target.value)} + autoComplete="current-password" + placeholder="Enter your password" + required + aria-invalid={error ? true : undefined} + className="h-11 pr-11 text-[14px]" + /> + +
      +
      -
      - - Encrypted in transit · JWT-secured session -
      -

      - FullStackHero Administration -

      -
      -
      - ); -} + {error && ( + + )} -// ─── subcomponents ─────────────────────────────────────────────────── +
      + +
      + -function DevDemoCallout({ - active, - copied, - onPick, - onCopy, -}: { - active: boolean; - copied: boolean; - onPick: () => void; - onCopy: () => void; -}) { - return ( -
      -
      - - - -
      -
      - Dev · demo account -
      - +
      )} - > - {active ? "loaded" : "use →"} - - +
      +
      -
      - - password - - {DEMO_PASSWORD} - - - +
      + + Encrypted in transit · JWT-secured session
      +

      + FullStackHero Administration +

      -
      + + {/* Demo dialog — rendered outside the shell to escape any overflow clipping. */} + {import.meta.env.DEV && ( + + )} + ); } diff --git a/clients/admin/src/pages/tenants/detail.tsx b/clients/admin/src/pages/tenants/detail.tsx index fd8b1f05ec..9c205e2dc1 100644 --- a/clients/admin/src/pages/tenants/detail.tsx +++ b/clients/admin/src/pages/tenants/detail.tsx @@ -4,12 +4,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Building2, + CalendarClock, CheckCircle2, CircleDashed, ClipboardList, Info, + KeyRound, Loader2, + Mail, RefreshCw, + ServerCrash, UserCog, XCircle, } from "lucide-react"; @@ -120,31 +124,36 @@ export function TenantDetailPage() { {tenant && ( <> - {/* Hero identity card */} - -
      -
      + {/* ── Hero identity card ─────────────────────────────────────── */} + +
      + {/* Left: monogram + name + meta + badges */} +
      -
      +

      {tenant.name}

      -
      - {tenant.id} - {tenant.adminEmail} +
      + + + {tenant.adminEmail} + + + + {tenant.id} +
      - + {tenant.isActive ? "Active" : "Inactive"} - - valid · {formatDate(tenant.validUpto)} + + + Valid until {formatDate(tenant.validUpto)} {tenant.issuer && ( - + iss · {tenant.issuer} )} @@ -152,7 +161,8 @@ export function TenantDetailPage() {
      -
      + {/* Right: action buttons */} +
      {canImpersonate && tenant.isActive && (
      ); } +/** + * ProvisioningPanel — live pipeline status. + * Renders a status badge + retry action, then either: + * - a "not tracked" neutral state (provisioningNotTracked) + * - a timeline-style step list + * - loading / empty / error states + * The 404/notTracked logic is intentionally preserved verbatim. + */ function ProvisioningPanel({ steps, status, @@ -276,6 +324,7 @@ function ProvisioningPanel({ retryPending: boolean; }) { const overall = notTracked ? "Not tracked" : status ?? (loading ? "Loading" : "Unknown"); + const overallVariant = status === "Completed" ? "success" @@ -287,45 +336,53 @@ function ProvisioningPanel({ return (
      + {/* Status bar */}
      - - {status === "Failed" - ? `Failed at ${currentStep ?? "unknown step"}` - : currentStep - ? `${overall} · ${currentStep}` - : overall} - +
      + + + {status === "Failed" + ? `Failed at ${currentStep ?? "unknown step"}` + : currentStep + ? `${overall} · ${currentStep}` + : overall} + +
      {status === "Failed" && ( )}
      + {/* Body */} {error ? ( ) : loading && steps.length === 0 ? ( -

      Loading…

      +

      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. -

      +
      + +

      + 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.

      ) : ( -
        - {steps.map((step, i) => ( - - ))} -
      + )} + {/* Error body (failed step detail) */} {errorBody && ( -
      +        
                 {errorBody}
               
      )} @@ -333,23 +390,97 @@ function ProvisioningPanel({ ); } -function StepRow({ step, index }: { step: TenantProvisioningStep; index: number }) { - const tone = - step.status === "Completed" - ? "text-[var(--color-success)]" - : step.status === "Failed" - ? "text-[var(--color-destructive)]" - : step.status === "Running" - ? "text-[var(--color-info)]" - : "text-[var(--color-muted-foreground)]"; - const Icon = - step.status === "Completed" - ? CheckCircle2 - : step.status === "Failed" - ? XCircle - : step.status === "Running" - ? Loader2 - : CircleDashed; +/** + * OverallStatusDot — an animated pulsing dot that conveys overall + * pipeline status at a glance alongside the badge text. + */ +function OverallStatusDot({ status }: { status: string }) { + const color = + status === "Completed" + ? "bg-[var(--color-success)]" + : status === "Failed" + ? "bg-[var(--color-destructive)]" + : status === "Running" + ? "bg-[var(--color-info)]" + : "bg-[var(--color-muted-foreground)]"; + + const pulse = status === "Running"; + + return ( + + {pulse && ( + + )} + + + ); +} + +/** + * StepTimeline — renders provisioning steps as a connected vertical + * timeline instead of a flat `
        ` with dividers. + * Each step has: a status icon track, step name, duration, and status label. + */ +function StepTimeline({ steps }: { steps: TenantProvisioningStep[] }) { + return ( +
          + {steps.map((step, i) => ( + + ))} +
        + ); +} + +function StepRow({ + step, + index, + isLast, +}: { + step: TenantProvisioningStep; + index: number; + isLast: boolean; +}) { + const isCompleted = step.status === "Completed"; + const isFailed = step.status === "Failed"; + const isRunning = step.status === "Running"; + const isPending = !isCompleted && !isFailed && !isRunning; + + const iconColor = isCompleted + ? "text-[var(--color-success)]" + : isFailed + ? "text-[var(--color-destructive)]" + : isRunning + ? "text-[var(--color-info)]" + : "text-[var(--color-muted-foreground)]"; + + const Icon = isCompleted + ? CheckCircle2 + : isFailed + ? XCircle + : isRunning + ? Loader2 + : CircleDashed; + + const trackColor = isCompleted + ? "bg-[var(--color-success)]" + : isFailed + ? "bg-[var(--color-destructive)]" + : isRunning + ? "bg-[var(--color-info)]" + : "bg-[var(--color-border-strong)]"; + + const statusVariant = isCompleted + ? "success" + : isFailed + ? "danger" + : isRunning + ? "info" + : ("outline" as const); const duration = step.startedUtc && step.completedUtc @@ -359,23 +490,71 @@ function StepRow({ step, index }: { step: TenantProvisioningStep; index: number : null; return ( -
      1. - - {String(index).padStart(2, "0")} - - - {step.step} - {duration && ( - - {duration} +
      2. + {/* Timeline track column */} +
        + {/* Icon */} + + - )} - {step.status} + {/* Vertical connector line — hidden on last item */} + {!isLast && ( +
        + )} +
        + + {/* Content */} +
        + {/* Step index + name */} +
        + + {String(index).padStart(2, "0")} + + + {step.step} + +
        + + {/* Duration + status */} +
        + {duration && ( + + {duration} + + )} + + {step.status} + +
        +
      3. ); } -// ─── helpers ──────────────────────────────────────────────────────────── +// ─── helpers ──────────────────────────────────────────────────────────────── function formatDate(value: string | undefined): string { if (!value) return "—"; From 6c15e6fe63d24024f37df332e60f2a38b8aa915a Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 04:41:00 +0530 Subject: [PATCH 09/15] feat(admin): new-tenant creation as a modern dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the full /tenants/new page with a CreateTenantDialog launched from the Tenants list "New tenant" button (same fields, validation, operator-supplied admin password, and createTenant mutation; on success closes, refreshes the list, and routes to the new tenant). /tenants/new now redirects to /tenants so existing links don't dead-end. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tenants/create-tenant-dialog.tsx | 324 ++++++++++++++++++ clients/admin/src/pages/tenants/create.tsx | 217 +----------- clients/admin/src/pages/tenants/list.tsx | 6 +- clients/admin/src/routes.tsx | 9 +- 4 files changed, 340 insertions(+), 216 deletions(-) create mode 100644 clients/admin/src/components/tenants/create-tenant-dialog.tsx diff --git a/clients/admin/src/components/tenants/create-tenant-dialog.tsx b/clients/admin/src/components/tenants/create-tenant-dialog.tsx new file mode 100644 index 0000000000..44032cc637 --- /dev/null +++ b/clients/admin/src/components/tenants/create-tenant-dialog.tsx @@ -0,0 +1,324 @@ +import { useNavigate } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Building2, + Database, + KeyRound, + UserRound, + Sparkles, +} from "lucide-react"; +import { toast } from "sonner"; +import { createTenant } from "@/api/tenants"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field } from "@/components/list"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogBody, + DialogFooter, +} from "@/components/ui/dialog"; +import { ApiRequestError } from "@/lib/api-client"; + +// ─── Schema (identical to the old page) ───────────────────────────────────── + +const TENANT_ID_RE = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/; + +const schema = z.object({ + id: z + .string() + .trim() + .regex( + TENANT_ID_RE, + "Lowercase letters, digits, hyphens. 3–64 chars. No leading/trailing hyphen.", + ), + name: z.string().trim().min(2, "At least 2 characters.").max(128), + adminEmail: z.string().trim().email("Enter a valid email."), + adminPassword: z + .string() + .min(8, "At least 8 characters.") + .max(128, "Maximum 128 characters."), + issuer: z.string().trim().min(2, "Required.").max(256), + connectionString: z.string().trim().max(2048).optional(), +}); + +type FormValues = z.infer; + +// ─── Section header (inline, no card — we're inside the dialog already) ───── + +function SectionLabel({ + icon: Icon, + title, + description, +}: { + icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" }>; + title: string; + description: string; +}) { + return ( +
        + + + +
        +

        {title}

        +

        + {description} +

        +
        +
        + ); +} + +// ─── Dialog ────────────────────────────────────────────────────────────────── + +export function CreateTenantDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + id: "", + name: "", + adminEmail: "", + adminPassword: "", + issuer: "", + connectionString: "", + }, + }); + + const mutation = useMutation({ + // Pass values via mutate(arg) — no closed-over state captured at submit time. + mutationFn: (values: FormValues) => + createTenant({ + id: values.id, + name: values.name, + adminEmail: values.adminEmail, + adminPassword: values.adminPassword, + issuer: values.issuer, + connectionString: values.connectionString?.trim() ? values.connectionString : null, + }), + onSuccess: (result) => { + toast.success(`Tenant ${result.id} created`, { + description: + "Provisioning runs in the background. Track progress on the detail page.", + }); + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + handleClose(); + navigate(`/tenants/${result.id}`); + }, + onError: (err) => { + const detail = + err instanceof ApiRequestError + ? err.problem?.detail ?? err.problem?.title ?? err.message + : (err as Error).message; + toast.error("Create failed", { description: detail }); + }, + }); + + function handleClose() { + reset(); + onOpenChange(false); + } + + const onSubmit = handleSubmit((values) => mutation.mutate(values)); + const submitting = isSubmitting || mutation.isPending; + + return ( + { + if (!o) handleClose(); + else onOpenChange(true); + }} + > + + {/* ── Header ── */} + +
        + {/* Icon badge — geometric accent tile */} + + + {/* Subtle sparkle accent — top-right corner */} + + +
        + New tenant +
        +
        + + Provision a new tenant and its seed admin user. The identifier is the + URL-safe slug used in subdomain-like routing and JWT claims. + +
        + + {/* ── Form ── */} +
        + + {/* ── Identity section ── */} +
        + + {/* Ruled separator */} +
        +
        + + + + + + + + + + + + + + + +
        +
        + + {/* ── Security section ── */} +
        + +
        + + + +
        + + {/* ── Database section ── */} +
        + +
        + + + +
        + + + {/* ── Footer ── */} + + + + + + +
        + ); +} diff --git a/clients/admin/src/pages/tenants/create.tsx b/clients/admin/src/pages/tenants/create.tsx index 734b470ca2..1481bdca70 100644 --- a/clients/admin/src/pages/tenants/create.tsx +++ b/clients/admin/src/pages/tenants/create.tsx @@ -1,212 +1,11 @@ -import { useNavigate } from "react-router-dom"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { ArrowLeft, Building2, Database, KeyRound, UserRound } from "lucide-react"; -import { toast } from "sonner"; -import { createTenant } from "@/api/tenants"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - EntityPageHeader, - Field, - SettingsSection, -} from "@/components/list"; -import { ApiRequestError } from "@/lib/api-client"; - -const TENANT_ID_RE = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/; - -const schema = z.object({ - id: z - .string() - .trim() - .regex(TENANT_ID_RE, "Lowercase letters, digits, hyphens. 3–64 chars. No leading/trailing hyphen."), - name: z.string().trim().min(2, "At least 2 characters.").max(128), - adminEmail: z.string().trim().email("Enter a valid email."), - adminPassword: z - .string() - .min(8, "At least 8 characters.") - .max(128, "Maximum 128 characters."), - issuer: z.string().trim().min(2, "Required.").max(256), - connectionString: z.string().trim().max(2048).optional(), -}); - -type FormValues = z.infer; +/** + * /tenants/new — redirects to the tenant list. + * + * Tenant creation is now a dialog launched from the list page "New tenant" + * button. This redirect keeps any existing bookmarks or links working. + */ +import { Navigate } from "react-router-dom"; export function CreateTenantPage() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - id: "", - name: "", - adminEmail: "", - adminPassword: "", - issuer: "", - connectionString: "", - }, - }); - - const mutation = useMutation({ - mutationFn: (values: FormValues) => - createTenant({ - id: values.id, - name: values.name, - adminEmail: values.adminEmail, - adminPassword: values.adminPassword, - issuer: values.issuer, - connectionString: values.connectionString?.trim() ? values.connectionString : null, - }), - onSuccess: (result) => { - toast.success(`Tenant ${result.id} created`, { - description: "Provisioning runs in the background. Track progress on the detail page.", - }); - queryClient.invalidateQueries({ queryKey: ["tenants"] }); - navigate(`/tenants/${result.id}`); - }, - onError: (err) => { - const detail = - err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message - : (err as Error).message; - toast.error("Create failed", { description: detail }); - }, - }); - - const onSubmit = handleSubmit((values) => mutation.mutate(values)); - const submitting = isSubmitting || mutation.isPending; - - return ( -
        - - - - -
        - -
        - - - - - - - - - - - - -
        -
        - - - - - - - - - - - - - -
        - - -
        -
        -
        - ); + return ; } diff --git a/clients/admin/src/pages/tenants/list.tsx b/clients/admin/src/pages/tenants/list.tsx index 19f2a4aa63..3ba41c4e42 100644 --- a/clients/admin/src/pages/tenants/list.tsx +++ b/clients/admin/src/pages/tenants/list.tsx @@ -8,6 +8,7 @@ import { Monogram } from "@/components/monogram"; import { EntityPageHeader, ErrorBand } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; +import { CreateTenantDialog } from "@/components/tenants/create-tenant-dialog"; const PAGE_SIZE = 12; @@ -21,6 +22,7 @@ function formatDate(value: string): string { export function TenantsListPage() { const [pageNumber, setPageNumber] = useState(1); + const [createOpen, setCreateOpen] = useState(false); const navigate = useNavigate(); const query = useQuery({ @@ -54,7 +56,7 @@ export function TenantsListPage() { } >
      )} + +
      ); } diff --git a/clients/admin/src/routes.tsx b/clients/admin/src/routes.tsx index 4e11925cd4..557421c66a 100644 --- a/clients/admin/src/routes.tsx +++ b/clients/admin/src/routes.tsx @@ -27,7 +27,6 @@ const lazyNamed = ( }); const TenantsListPage = lazyNamed(() => import("@/pages/tenants/list"), "TenantsListPage"); -const CreateTenantPage = lazyNamed(() => import("@/pages/tenants/create"), "CreateTenantPage"); const TenantDetailPage = lazyNamed(() => import("@/pages/tenants/detail"), "TenantDetailPage"); const UsersListPage = lazyNamed(() => import("@/pages/users/list"), "UsersListPage"); const CreateUserPage = lazyNamed(() => import("@/pages/users/create"), "CreateUserPage"); @@ -94,12 +93,10 @@ export const router = createBrowserRouter([ ), }, { + // /tenants/new — creation is now a dialog on the list page. + // Redirect any bookmarked links back to /tenants. path: "tenants/new", - element: ( - - - - ), + element: , }, { path: "tenants/:id", From db6b5a7600b6c7eb3364991d03d10d47cf392349 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 04:45:00 +0530 Subject: [PATCH 10/15] feat(admin): audit detail as a sheet + modern filter select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audits: detail now opens as a side Sheet from the list (selected-row state) instead of a full-page route, mirroring the dashboard; /audits/:id redirects to /audits. Same audit query, sections, and fields preserved. - ui/select: new reusable modern single-select built on the DropdownMenu primitive (no new deps) — rounded trigger + chevron, rose-tinted active item. Replaces the raw native filter controls + * throughout the admin app. + * + * Design goals: + * • Trigger button matches the outline Button visual language (border, + * rounded-lg, h-9, font-sans text-sm). + * • Selected option gets a rose brand check mark and tinted background. + * • Content panel shares the frosted/gradient-border vocabulary already + * used by DropdownMenuContent (no new visual primitives). + * • Fully keyboard-accessible via Radix roving focus. + */ +export function Select({ + value, + onChange, + options, + placeholder, + label, + className, + disabled = false, + minWidth = "10rem", +}: SelectProps) { + const allOptions: SelectOption[] = placeholder + ? [{ value: "", label: placeholder }, ...options] + : [...options]; + + const current = + allOptions.find((o) => o.value === value) ?? + (placeholder ? { value: "", label: placeholder } : allOptions[0]); + + const displayLabel = current?.label ?? placeholder ?? "Select…"; + const hasSelection = value !== ""; + + return ( +
      + {label && ( + + {label} + + )} + + + + {displayLabel} + + + + + {allOptions.map((opt) => { + const selected = opt.value === value; + return ( + onChange(opt.value as T | "")} + className={cn( + selected && [ + // Rose-primary active state — tinted background + primary text + "bg-[oklch(from_var(--color-primary)_l_c_h_/_0.08)]", + "text-[var(--color-primary)]", + "data-[highlighted]:bg-[oklch(from_var(--color-primary)_l_c_h_/_0.12)]", + "data-[highlighted]:text-[var(--color-primary)]", + ], + )} + > + + {opt.label} + {opt.hint && ( + + — {opt.hint} + + )} + + {selected && ( + + )} + + ); + })} + + +
      + ); +} diff --git a/clients/admin/src/pages/audits/detail.tsx b/clients/admin/src/pages/audits/detail.tsx index e619f6914a..96f6f1b185 100644 --- a/clients/admin/src/pages/audits/detail.tsx +++ b/clients/admin/src/pages/audits/detail.tsx @@ -1,83 +1,136 @@ import { useMemo, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { AlertTriangle, - ArrowLeft, Check, ClipboardCheck, Copy, FileText, Fingerprint, ScrollText, + X, } from "lucide-react"; import { getAudit, type AuditDetailDto } from "@/api/audits"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { EntityPageHeader, ErrorBand, LoadingRow, SettingsSection } from "@/components/list"; +import { ErrorBand, LoadingRow } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; +import { + Sheet, + SheetContent, +} from "@/components/ui/dialog"; import { cn } from "@/lib/cn"; -/** - * Audit detail — forensic record view. - * - * Layout top to bottom: - * 1. EntityPageHeader — icon + event-type + occurred-at - * 2. Identity strip — event-type + severity chips + source - * 3. Correlation strip — trace / span / correlation / request IDs as - * copyable chips. - * 4. Context grid — fact tiles. - * 5. Payload viewer — full-width JSON pane with bounded inner scroll. - */ -export function AuditDetailPage() { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); +// ───────────────────────────────────────────────────────────────────────── +// AuditDetailSheet — side sheet shown when an audit row is clicked on the +// list page. Fetches the full record by id and renders the same 4 sections +// the old full-page route showed. +// ───────────────────────────────────────────────────────────────────────── + +export interface AuditDetailSheetProps { + /** The audit id to load, or null / undefined when the sheet is closed. */ + auditId: string | null | undefined; + onClose: () => void; +} + +export function AuditDetailSheet({ auditId, onClose }: AuditDetailSheetProps) { + const open = Boolean(auditId); + return ( + !v && onClose()}> + + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────── +// Inner body — data-fetch + layout. Exported so it can be composed +// independently if needed. +// ───────────────────────────────────────────────────────────────────────── + +export function AuditDetailSheetBody({ + auditId, + onClose, +}: { + auditId: string | null; + onClose: () => void; +}) { const query = useQuery({ - queryKey: ["audits", id], - queryFn: () => getAudit(id!), - enabled: Boolean(id), + queryKey: ["audits", auditId], + queryFn: () => getAudit(auditId!), + enabled: Boolean(auditId), + staleTime: 60_000, }); const event = query.data; return ( -
      -
      - - +
      + {/* Header */} +
      +
      + + + +
      +
      + {event ? `${formatEventType(event.eventType)} event` : "Audit event"} +
      +
      + {event ? formatTimestamp(event.occurredAtUtc) : "Loading…"} +
      +
      +
      +
      - {query.isError && ( - - )} + {/* Scrollable body */} +
      + {query.isLoading && !event && ( +
      + +
      + )} - {query.isLoading && !event && } + {query.isError && ( +
      + +
      + )} - {event && ( - <> - - - - - - )} + {event && ( +
      + + + + +
      + )} +
      ); } @@ -90,8 +143,11 @@ function IdentityBand({ event }: { event: AuditDetailDto }) { const sev = severityTone(event.severity); const eventLabel = formatEventType(event.eventType); return ( - -
      +
      +
      + Identity +
      +
      - +
      ); } // ───────────────────────────────────────────────────────────────────────── -// 2. Correlation band — the most-used widget on the page. +// 2. Correlation band — copyable ID chips. // ───────────────────────────────────────────────────────────────────────── function CorrelationBand({ event }: { event: AuditDetailDto }) { @@ -131,23 +187,28 @@ function CorrelationBand({ event }: { event: AuditDetailDto }) { ]; return ( - -
      +
      +
      + + + Correlation + + + — paste into your observability stack + +
      +
      {slots.map((s) => ( ))}
      - +
      ); } function CorrelationChip({ label, value }: { label: string; value: string | null }) { const [copied, setCopied] = useState(false); - const hasValue = value && value !== "—"; + const hasValue = Boolean(value && value !== "—"); const onCopy = async () => { if (!hasValue) return; @@ -166,18 +227,18 @@ function CorrelationChip({ label, value }: { label: string; value: string | null onClick={onCopy} disabled={!hasValue} className={cn( - "group/chip flex min-w-0 items-start gap-3 px-5 py-3.5 text-left transition-colors sm:px-6", + "group/chip flex min-w-0 items-start gap-3 rounded-lg border border-[var(--color-border)] px-3 py-2.5 text-left transition-colors", hasValue ? "hover:bg-[var(--color-muted)]/50" : "opacity-60", )} aria-label={hasValue ? `Copy ${label}` : `${label} not available`} > -
      -
      +
      +
      {label}
      -
      +
      {hasValue ? value : "—"}
      @@ -185,12 +246,12 @@ function CorrelationChip({ label, value }: { label: string; value: string | null - {copied ? : } + {copied ? : } )} @@ -198,7 +259,7 @@ function CorrelationChip({ label, value }: { label: string; value: string | null } // ───────────────────────────────────────────────────────────────────────── -// 3. Context grid — auto-fit fact tiles. +// 3. Context grid — who/where/when fact tiles. // ───────────────────────────────────────────────────────────────────────── function ContextGrid({ event }: { event: AuditDetailDto }) { @@ -218,20 +279,22 @@ function ContextGrid({ event }: { event: AuditDetailDto }) { ]; return ( - +
      +
      + + + Context + +
      {tiles.map((t) => ( ))}
      - +
      ); } @@ -245,12 +308,14 @@ function FactTile({ mono?: boolean; }) { return ( -
      -
      {label}
      +
      +
      + {label} +
      {value} @@ -260,7 +325,7 @@ function FactTile({ } // ───────────────────────────────────────────────────────────────────────── -// 4. Payload — full-width JSON pane. +// 4. Payload — JSON pane with copy button. // ───────────────────────────────────────────────────────────────────────── function PayloadPanel({ payload }: { payload: unknown }) { @@ -280,33 +345,33 @@ function PayloadPanel({ payload }: { payload: unknown }) { const lineCount = useMemo(() => json.split("\n").length, [json]); return ( - +
      +
      +
      + + + Payload + + + · {lineCount} lines + +
      + - } - > - {/* min-w-0 prevents the pre from blowing out the page width */} -
      -
      -          {json}
      -        
      - +
      +        {json}
      +      
      +
      ); } diff --git a/clients/admin/src/pages/audits/list.tsx b/clients/admin/src/pages/audits/list.tsx index ab8b8d104e..cf50c91e65 100644 --- a/clients/admin/src/pages/audits/list.tsx +++ b/clients/admin/src/pages/audits/list.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, useEffect } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { ChevronRight, RefreshCw, ScrollText, X } from "lucide-react"; import { @@ -28,6 +28,7 @@ import { import { EmptyState } from "@/components/empty-state"; import { ApiRequestError } from "@/lib/api-client"; import { AuditingPermissions } from "@/lib/permissions"; +import { AuditDetailSheet } from "@/pages/audits/detail"; import { cn } from "@/lib/cn"; const PAGE_SIZE = 25; @@ -37,8 +38,8 @@ const PAGE_SIZE = 25; const SEARCH_DEBOUNCE_MS = 250; export function AuditsListPage() { - const navigate = useNavigate(); const { user } = useAuth(); + const [selectedId, setSelectedId] = useState(null); const [params, setParams] = useSearchParams(); const canCrossTenant = (user?.permissions ?? []).includes( @@ -240,7 +241,7 @@ export function AuditsListPage() { {items.length > 0 && (
        {items.map((event) => ( - navigate(`/audits/${event.id}`)} /> + setSelectedId(event.id)} /> ))}
      )} @@ -259,6 +260,9 @@ export function AuditsListPage() { noun="events" /> )} + + {/* Audit detail side sheet */} + setSelectedId(null)} />
      ); } diff --git a/clients/admin/src/pages/impersonation/list.tsx b/clients/admin/src/pages/impersonation/list.tsx index 7be25b83e3..9120543bd6 100644 --- a/clients/admin/src/pages/impersonation/list.tsx +++ b/clients/admin/src/pages/impersonation/list.tsx @@ -17,8 +17,8 @@ import { StatStrip, Stat, FilterBar, - Select, } from "@/components/list"; +import { Select } from "@/components/ui/select"; import { EmptyState } from "@/components/empty-state"; import { ImpersonateDialog } from "@/components/impersonation/impersonate-dialog"; import { RevokeGrantDialog } from "@/components/impersonation/revoke-grant-dialog"; @@ -102,10 +102,10 @@ export function ImpersonationListPage() { setRoleId(e.target.value)} - className="h-8 rounded-md border border-[var(--color-border)] bg-transparent px-2 text-xs font-mono normal-case tracking-normal text-[var(--color-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--color-ring)]" - > - - {rolesQuery.data?.map((r) => ( - - ))} - - + -
      -
      +
      +
      {label}
      @@ -305,13 +298,17 @@ function BrandAssetsEditor({ onChange: (next: Partial) => void; }) { return ( -
      -

      Brand assets

      -

      - URLs to your hosted brand assets. For uploads, host the file via the - Files module first and paste the resulting public URL here. -

      -
      +
      +
      +

      + Brand assets +

      +

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

      +
      +
      { (e.currentTarget as HTMLImageElement).style.display = "none"; }} - className="h-9 w-9 shrink-0 rounded-md object-contain ring-1 ring-inset ring-[var(--color-border)] bg-[var(--color-background)]" + className="h-9 w-9 shrink-0 rounded-lg object-contain ring-1 ring-inset ring-[var(--color-border)] bg-[var(--color-background)]" /> )}
      @@ -383,70 +380,98 @@ function AssetField({ } // ───────────────────────────────────────────────────────────────────────── -// Live preview swatch +// Live preview — shows how primary-action buttons + surface tokens render // ───────────────────────────────────────────────────────────────────────── function ThemePreview({ palette, label }: { palette: PaletteDto; label: string }) { return (
      -
      // {label}
      + {/* Preview header bar */}
      -
      - - 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 - - - Secondary - - - warn - - + + live + +
      + + {/* Preview body */} +
      +
      +
      + + Sample tenant page + + + active + +
      +

      - error - + 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 + +
      diff --git a/clients/admin/src/pages/settings/layout.tsx b/clients/admin/src/pages/settings/layout.tsx index 19225c54e0..5d58e8a01b 100644 --- a/clients/admin/src/pages/settings/layout.tsx +++ b/clients/admin/src/pages/settings/layout.tsx @@ -1,64 +1,206 @@ -import { NavLink, Outlet } from "react-router-dom"; -import { MonitorSmartphone, Palette, Settings, ShieldCheck, UserRound } from "lucide-react"; +import { NavLink, Outlet, useLocation } from "react-router-dom"; +import { + ChevronRight, + MonitorSmartphone, + Palette, + Settings, + ShieldCheck, + UserRound, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { EntityPageHeader } from "@/components/list"; import { cn } from "@/lib/cn"; type Tab = { to: string; label: string; - icon: React.ComponentType<{ className?: string }>; + hint: string; + icon: LucideIcon; }; const TABS: Tab[] = [ - { to: "/settings/profile", label: "Profile", icon: UserRound }, - { to: "/settings/security", label: "Security", icon: ShieldCheck }, - { to: "/settings/sessions", label: "Sessions", icon: MonitorSmartphone }, - { to: "/settings/appearance", label: "Appearance", icon: Palette }, + { + to: "/settings/profile", + label: "Profile", + hint: "Your identity and avatar", + icon: UserRound, + }, + { + to: "/settings/security", + label: "Security", + hint: "Password and two-factor auth", + icon: ShieldCheck, + }, + { + to: "/settings/sessions", + label: "Sessions", + hint: "Active devices and sign-outs", + icon: MonitorSmartphone, + }, + { + to: "/settings/appearance", + label: "Appearance", + hint: "Theme and visual preferences", + icon: Palette, + }, ]; +const pad2 = (n: number) => n.toString().padStart(2, "0"); + /** - * SettingsLayout — EntityPageHeader + underline tab nav + outlet. - * Console aesthetic: underline indicator with chartreuse accent signal, - * mono-caps icon labels, hairline rule — no sidebar, no brand-soft pills. + * SettingsLayout — editorial numbered left-nav + content parity with the + * dashboard settings shell. Desktop: `lg:grid-cols-[260px_1fr]` with a + * sticky vertical "Sections" rail; active item shows a primary brand bar on + * the left edge. Mobile: horizontal pill tabs (overflow-x scroll). Masthead + * resolves to "Settings · {active section}" so the active context is always + * visible at page level. Child routes render via . */ export function SettingsLayout() { + const location = useLocation(); + const activeIndex = Math.max( + 0, + TABS.findIndex((t) => location.pathname.startsWith(t.to)), + ); + const active = TABS[activeIndex] ?? TABS[0]!; + return ( -
      +
      + {/* Page header — resolves to "Settings · {active section}" so the + active context is visible without stacking a second header. */} + Settings + + · + + + {active.label} + + + } + description={active.hint} /> - +
      + {/* ─── Editorial left nav ─── */} + -
      - -
      + {/* ─── Tab content ─── */} +
      +
      + +
      +
      +
      ); } From 10c3fa46abc277b801bbf12f0da88525a2662981 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 04:51:38 +0530 Subject: [PATCH 12/15] fix(admin): health check rows expand reliably (controlled disclosure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the native
      / check rows with a controlled useState toggle, and only render the expand affordance (chevron + click) when a check actually reports details. Fixes rows appearing unresponsive. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓. Co-Authored-By: Claude Opus 4.7 (1M context) --- clients/admin/src/pages/health/page.tsx | 96 ++++++++++++++++--------- 1 file changed, 61 insertions(+), 35 deletions(-) diff --git a/clients/admin/src/pages/health/page.tsx b/clients/admin/src/pages/health/page.tsx index 1927fb34d1..026360e131 100644 --- a/clients/admin/src/pages/health/page.tsx +++ b/clients/admin/src/pages/health/page.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Activity, ChevronRight, Heart, RefreshCw } from "lucide-react"; import { getLiveness, getReadiness, type HealthEntry, type HealthResult, type HealthStatus } from "@/api/health"; @@ -152,9 +153,7 @@ function ProbeSection({ } > {loading ? ( -
      - Probing -
      +
      Probing…
      ) : !result || result.results.length === 0 ? (
      @@ -177,41 +176,68 @@ function ProbeSection({ } function CheckRow({ entry }: { entry: HealthEntry }) { + const [open, setOpen] = useState(false); + const hasDetails = !!entry.details && Object.keys(entry.details).length > 0; + + const rowInner = ( + <> + +
      +
      {entry.name}
      + {entry.description && ( +
      + {entry.description} +
      + )} +
      + + {entry.durationMs.toFixed(1)}ms + + {hasDetails ? ( + + ) : ( + + )} + + ); + return (
    • -
      - - -
      -
      {entry.name}
      - {entry.description && ( -
      - {entry.description} + {hasDetails ? ( + + ) : ( +
      + {rowInner} +
      + )} + {hasDetails && open && ( +
      +
      + {Object.entries(entry.details ?? {}).map(([k, v]) => ( +
      +
      + {k} +
      +
      + {String(v)} +
      - )} -
      - - {entry.durationMs.toFixed(1)}ms - - -
      - {entry.details && Object.keys(entry.details).length > 0 && ( -
      -
      - {Object.entries(entry.details).map(([k, v]) => ( -
      -
      - {k} -
      -
      - {String(v)} -
      -
      - ))} -
      -
      - )} -
      + ))} + +
    • + )} ); } From 6e550f7609529f3fd0aa9f3019242b4dfae97cae Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 04:56:34 +0530 Subject: [PATCH 13/15] feat(admin): user + role creation as modern dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the new-tenant dialog: user and role creation now open well-designed popup dialogs from their list "New …" buttons instead of separate pages. Same fields/validation/mutations (registerUser, upsertRole); on success close, refresh the list query, and route to the created entity. /users/new and /roles/new redirect to their lists. Part of the admin → dashboard design unification (PR #1268). Build: admin `npm run build` ✓, `eslint` ✓ (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/roles/create-role-dialog.tsx | 165 +++++++++ .../components/users/create-user-dialog.tsx | 327 ++++++++++++++++++ clients/admin/src/pages/roles/create.tsx | 137 +------- clients/admin/src/pages/roles/list.tsx | 8 +- clients/admin/src/pages/users/create.tsx | 238 +------------ clients/admin/src/pages/users/list.tsx | 6 +- clients/admin/src/routes.tsx | 18 +- 7 files changed, 519 insertions(+), 380 deletions(-) create mode 100644 clients/admin/src/components/roles/create-role-dialog.tsx create mode 100644 clients/admin/src/components/users/create-user-dialog.tsx diff --git a/clients/admin/src/components/roles/create-role-dialog.tsx b/clients/admin/src/components/roles/create-role-dialog.tsx new file mode 100644 index 0000000000..95fb0628f7 --- /dev/null +++ b/clients/admin/src/components/roles/create-role-dialog.tsx @@ -0,0 +1,165 @@ +import { useNavigate } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Shield } from "lucide-react"; +import { toast } from "sonner"; +import { upsertRole, type RoleDto } from "@/api/roles"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field } from "@/components/list"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogBody, + DialogFooter, +} from "@/components/ui/dialog"; +import { ApiRequestError } from "@/lib/api-client"; + +// ─── Schema (identical to the old create page) ─────────────────────────────── + +const schema = z.object({ + name: z + .string() + .trim() + .min(2, "At least 2 characters.") + .max(64, "Keep under 64 characters."), + description: z.string().trim().max(256, "Keep under 256 characters.").optional(), +}); + +type FormValues = z.infer; + +// ─── Dialog ─────────────────────────────────────────────────────────────────── + +export function CreateRoleDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: "", description: "" }, + }); + + const mutation = useMutation({ + // Pass values via mutate(arg) — no closed-over state captured at submit time. + mutationFn: (values) => + upsertRole({ + id: "", + name: values.name, + description: values.description?.trim() ? values.description : null, + }), + onSuccess: (result) => { + toast.success(`Role ${result.name} created`); + queryClient.invalidateQueries({ queryKey: ["roles"] }); + handleClose(); + navigate(`/roles/${result.id}`); + }, + onError: (err) => { + const detail = + err instanceof ApiRequestError + ? err.problem?.detail ?? err.problem?.title ?? err.message + : err.message; + toast.error("Create failed", { description: detail }); + }, + }); + + function handleClose() { + reset(); + onOpenChange(false); + } + + const onSubmit = handleSubmit((values) => mutation.mutate(values)); + const submitting = isSubmitting || mutation.isPending; + + return ( + { + if (!o) handleClose(); + else onOpenChange(true); + }} + > + + {/* ── Header ── */} + +
      + + + +
      + New role +
      +
      + + Create a role, then grant it permissions on its detail page. The role name is what shows + up in user role assignments — choose something descriptive. + +
      + + {/* ── Form ── */} +
      + + + + + + + + + + {/* ── Footer ── */} + + + + +
      +
      +
      + ); +} diff --git a/clients/admin/src/components/users/create-user-dialog.tsx b/clients/admin/src/components/users/create-user-dialog.tsx new file mode 100644 index 0000000000..adeb0580be --- /dev/null +++ b/clients/admin/src/components/users/create-user-dialog.tsx @@ -0,0 +1,327 @@ +import { useNavigate } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { KeyRound, User as UserIcon, Users } from "lucide-react"; +import { toast } from "sonner"; +import { registerUser } from "@/api/users"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field } from "@/components/list"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogBody, + DialogFooter, +} from "@/components/ui/dialog"; +import { ApiRequestError } from "@/lib/api-client"; + +// ─── Schema (identical to the old create page) ─────────────────────────────── + +const USERNAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{2,31}$/; + +const schema = z + .object({ + firstName: z.string().trim().min(1, "Required.").max(64), + lastName: z.string().trim().min(1, "Required.").max(64), + userName: z + .string() + .trim() + .regex(USERNAME_RE, "3–32 chars. Letters, digits, dot, dash, underscore. Start with a letter."), + email: z.string().trim().email("Enter a valid email."), + phoneNumber: z.string().trim().max(32).optional(), + password: z.string().min(8, "At least 8 characters."), + confirmPassword: z.string().min(8), + }) + .refine((d) => d.password === d.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords don't match.", + }); + +type FormValues = z.infer; + +// ─── Section label ──────────────────────────────────────────────────────────── + +function SectionLabel({ + icon: Icon, + title, + description, +}: { + icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" }>; + title: string; + description: string; +}) { + return ( +
      + + + +
      +

      {title}

      +

      + {description} +

      +
      +
      + ); +} + +// ─── Dialog ─────────────────────────────────────────────────────────────────── + +export function CreateUserDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + firstName: "", + lastName: "", + userName: "", + email: "", + phoneNumber: "", + password: "", + confirmPassword: "", + }, + }); + + const mutation = useMutation({ + // Pass values via mutate(arg) — no closed-over state captured at submit time. + mutationFn: (values: FormValues) => + registerUser({ + firstName: values.firstName, + lastName: values.lastName, + userName: values.userName, + email: values.email, + password: values.password, + confirmPassword: values.confirmPassword, + phoneNumber: values.phoneNumber?.trim() || undefined, + }), + onSuccess: (result) => { + toast.success("User created", { + description: result.message ?? "Confirmation email queued.", + }); + queryClient.invalidateQueries({ queryKey: ["users"] }); + handleClose(); + navigate(result.userId ? `/users/${result.userId}` : "/users"); + }, + onError: (err) => { + const detail = + err instanceof ApiRequestError + ? err.problem?.detail ?? err.problem?.title ?? err.message + : (err as Error).message; + toast.error("Create failed", { description: detail }); + }, + }); + + function handleClose() { + reset(); + onOpenChange(false); + } + + const onSubmit = handleSubmit((values) => mutation.mutate(values)); + const submitting = isSubmitting || mutation.isPending; + + return ( + { + if (!o) handleClose(); + else onOpenChange(true); + }} + > + + {/* ── Header ── */} + +
      + + + +
      + New account +
      +
      + + The new user is created in the current tenant and emailed a confirmation link. Roles can + be assigned from the detail page after creation. + +
      + + {/* ── Form ── */} +
      + + {/* ── Identity section ── */} +
      + +
      +
      +
      + + + + + + +
      + + + + + + + + + + + + +
      +
      + + {/* ── Credentials section ── */} +
      + +
      +
      + + + + + + +
      +
      + + + {/* ── Footer ── */} + + + + + + +
      + ); +} diff --git a/clients/admin/src/pages/roles/create.tsx b/clients/admin/src/pages/roles/create.tsx index 3bb76160f0..c362a8acca 100644 --- a/clients/admin/src/pages/roles/create.tsx +++ b/clients/admin/src/pages/roles/create.tsx @@ -1,135 +1,8 @@ -import { useNavigate } from "react-router-dom"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { ArrowLeft, Shield } from "lucide-react"; -import { toast } from "sonner"; -import { upsertRole, type RoleDto } from "@/api/roles"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - EntityPageHeader, - Field, - SettingsSection, -} from "@/components/list"; -import { ApiRequestError } from "@/lib/api-client"; - -const schema = z.object({ - name: z - .string() - .trim() - .min(2, "At least 2 characters.") - .max(64, "Keep under 64 characters."), - description: z.string().trim().max(256, "Keep under 256 characters.").optional(), -}); - -type FormValues = z.infer; +import { Navigate } from "react-router-dom"; +// Role creation is now a dialog launched from the list page. +// This page is kept (and the route lazily loads it) so that any bookmarked +// /roles/new links continue to work — they redirect seamlessly to /roles. export function CreateRolePage() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { name: "", description: "" }, - }); - - const mutation = useMutation({ - mutationFn: (values) => - upsertRole({ - id: "", - name: values.name, - description: values.description?.trim() ? values.description : null, - }), - onSuccess: (result) => { - toast.success(`Role ${result.name} created`); - queryClient.invalidateQueries({ queryKey: ["roles"] }); - navigate(`/roles/${result.id}`); - }, - onError: (err) => { - const detail = - err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message - : err.message; - toast.error("Create failed", { description: detail }); - }, - }); - - const onSubmit = handleSubmit((values) => mutation.mutate(values)); - const submitting = isSubmitting || mutation.isPending; - - return ( -
      - - - - -
      -
      - - - -
      - } - > -
      - - - - - - -
      - -
      - -
      - ); + return ; } diff --git a/clients/admin/src/pages/roles/list.tsx b/clients/admin/src/pages/roles/list.tsx index fb138c6357..0581be88a8 100644 --- a/clients/admin/src/pages/roles/list.tsx +++ b/clients/admin/src/pages/roles/list.tsx @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { EntityPageHeader, ErrorBand, LoadingRow } from "@/components/list"; import { EmptyState } from "@/components/empty-state"; import { ApiRequestError } from "@/lib/api-client"; +import { CreateRoleDialog } from "@/components/roles/create-role-dialog"; const ROOT_ROLE_NAMES = new Set(["Admin", "Basic"]); @@ -19,6 +20,7 @@ export function RolesListPage() { const navigate = useNavigate(); const [search, setSearch] = useState(""); const [debounced, setDebounced] = useState(""); + const [createOpen, setCreateOpen] = useState(false); useEffect(() => { const t = setTimeout(() => setDebounced(search.trim().toLowerCase()), 200); @@ -59,7 +61,7 @@ export function RolesListPage() { description="Define what people can do. Each role bundles a set of permissions; users inherit a role's permissions by being assigned to it." > } @@ -172,6 +174,8 @@ export function RolesListPage() {
      )} + +
      ); } diff --git a/clients/admin/src/pages/users/create.tsx b/clients/admin/src/pages/users/create.tsx index ce678e0dec..2f58683458 100644 --- a/clients/admin/src/pages/users/create.tsx +++ b/clients/admin/src/pages/users/create.tsx @@ -1,236 +1,8 @@ -import { useNavigate } from "react-router-dom"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { ArrowLeft, KeyRound, User as UserIcon, Users } from "lucide-react"; -import { toast } from "sonner"; -import { registerUser } from "@/api/users"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - EntityPageHeader, - Field, - SettingsSection, -} from "@/components/list"; -import { ApiRequestError } from "@/lib/api-client"; - -const USERNAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{2,31}$/; - -const schema = z - .object({ - firstName: z.string().trim().min(1, "Required.").max(64), - lastName: z.string().trim().min(1, "Required.").max(64), - userName: z - .string() - .trim() - .regex(USERNAME_RE, "3–32 chars. Letters, digits, dot, dash, underscore. Start with a letter."), - email: z.string().trim().email("Enter a valid email."), - phoneNumber: z.string().trim().max(32).optional(), - password: z.string().min(8, "At least 8 characters."), - confirmPassword: z.string().min(8), - }) - .refine((d) => d.password === d.confirmPassword, { - path: ["confirmPassword"], - message: "Passwords don't match.", - }); - -type FormValues = z.infer; +import { Navigate } from "react-router-dom"; +// User creation is now a dialog launched from the list page. +// This page is kept (and the route lazily loads it) so that any bookmarked +// /users/new links continue to work — they redirect seamlessly to /users. export function CreateUserPage() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - firstName: "", - lastName: "", - userName: "", - email: "", - phoneNumber: "", - password: "", - confirmPassword: "", - }, - }); - - const mutation = useMutation({ - mutationFn: registerUser, - onSuccess: (result) => { - toast.success("User created", { - description: result.message ?? "Confirmation email queued.", - }); - queryClient.invalidateQueries({ queryKey: ["users"] }); - navigate(result.userId ? `/users/${result.userId}` : "/users"); - }, - onError: (err) => { - const detail = - err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message - : (err as Error).message; - toast.error("Create failed", { description: detail }); - }, - }); - - const onSubmit = handleSubmit((values) => - mutation.mutate({ - firstName: values.firstName, - lastName: values.lastName, - userName: values.userName, - email: values.email, - password: values.password, - confirmPassword: values.confirmPassword, - phoneNumber: values.phoneNumber?.trim() || undefined, - }), - ); - - const submitting = isSubmitting || mutation.isPending; - - return ( -
      - - - - -
      -
      - -
      -
      - - - - - - -
      - - - - - - - - - - - - -
      -
      - - - - -
      - } - > -
      - - - - - - -
      - -
      - -
      - ); + return ; } diff --git a/clients/admin/src/pages/users/list.tsx b/clients/admin/src/pages/users/list.tsx index 1d07aedbb9..d69fb86b57 100644 --- a/clients/admin/src/pages/users/list.tsx +++ b/clients/admin/src/pages/users/list.tsx @@ -10,6 +10,7 @@ import { Monogram } from "@/components/monogram"; import { EntityPageHeader, ErrorBand } from "@/components/list"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; +import { CreateUserDialog } from "@/components/users/create-user-dialog"; const PAGE_SIZE = 12; @@ -34,6 +35,7 @@ export function UsersListPage() { const [activeFilter, setActiveFilter] = useState("any"); const [confirmedFilter, setConfirmedFilter] = useState("any"); const [roleId, setRoleId] = useState(""); + const [createOpen, setCreateOpen] = useState(false); // Debounce the search input → searchTerm useEffect(() => { @@ -106,7 +108,7 @@ export function UsersListPage() { : "Loading the roster…"} >
      )} + +
      ); } diff --git a/clients/admin/src/routes.tsx b/clients/admin/src/routes.tsx index 10f935f63c..adf5af27a8 100644 --- a/clients/admin/src/routes.tsx +++ b/clients/admin/src/routes.tsx @@ -29,10 +29,8 @@ const lazyNamed = ( const TenantsListPage = lazyNamed(() => import("@/pages/tenants/list"), "TenantsListPage"); const TenantDetailPage = lazyNamed(() => import("@/pages/tenants/detail"), "TenantDetailPage"); const UsersListPage = lazyNamed(() => import("@/pages/users/list"), "UsersListPage"); -const CreateUserPage = lazyNamed(() => import("@/pages/users/create"), "CreateUserPage"); const UserDetailPage = lazyNamed(() => import("@/pages/users/detail"), "UserDetailPage"); const RolesListPage = lazyNamed(() => import("@/pages/roles/list"), "RolesListPage"); -const CreateRolePage = lazyNamed(() => import("@/pages/roles/create"), "CreateRolePage"); const RoleDetailPage = lazyNamed(() => import("@/pages/roles/detail"), "RoleDetailPage"); const BillingLayout = lazyNamed(() => import("@/pages/billing/layout"), "BillingLayout"); const PlansListPage = lazyNamed(() => import("@/pages/billing/plans-list"), "PlansListPage"); @@ -116,12 +114,10 @@ export const router = createBrowserRouter([ ), }, { + // /users/new — creation is now a dialog on the list page. + // Redirect any bookmarked links back to /users. path: "users/new", - element: ( - - - - ), + element: , }, { path: "users/:id", @@ -142,12 +138,10 @@ export const router = createBrowserRouter([ ), }, { + // /roles/new — creation is now a dialog on the list page. + // Redirect any bookmarked links back to /roles. path: "roles/new", - element: ( - - - - ), + element: , }, { path: "roles/:id", From 34a28e7e9c12b1dd69b0fd0cf336f796d228402b Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 04:58:20 +0530 Subject: [PATCH 14/15] =?UTF-8?q?chore(dashboard):=20flatten=20impersonati?= =?UTF-8?q?on=20banner=20gradient=20+=20rename=20"Console"=20=E2=86=92=20"?= =?UTF-8?q?Dashboard"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - impersonation-banner: drop the radial-gradient tone wash and the icon square's linear-gradient; keep the solid muted background, tone border, and cross-tenant left ribbon. - sidebar/mobile-nav: the logo sub-label and footer now read "Dashboard" instead of "Console". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/layout/impersonation-banner.tsx | 15 +-------------- .../src/components/layout/mobile-nav.tsx | 2 +- .../dashboard/src/components/layout/sidebar.tsx | 4 ++-- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/clients/dashboard/src/components/layout/impersonation-banner.tsx b/clients/dashboard/src/components/layout/impersonation-banner.tsx index 6c122493b3..3e367ee5a9 100644 --- a/clients/dashboard/src/components/layout/impersonation-banner.tsx +++ b/clients/dashboard/src/components/layout/impersonation-banner.tsx @@ -79,19 +79,6 @@ export function ImpersonationBanner() { backgroundColor: "var(--color-muted)", }} > - {/* Soft tone wash — radial in the tone color from the top-left. - Replaces the previous diagonal-stripe alarm with the editorial - surface treatment used on the overview welcome panel. */} - - {/* Left ribbon — 2px tone strip only for cross-tenant. Subtle but scannable for an operator skimming the chrome. */} {isCrossTenant && ( @@ -109,7 +96,7 @@ export function ImpersonationBanner() { aria-hidden className="grid h-8 w-8 shrink-0 place-items-center rounded-xl" style={{ - background: `linear-gradient(135deg, oklch(from ${tone} l c h / 0.22), oklch(from ${tone} l c h / 0.04))`, + backgroundColor: `oklch(from ${tone} l c h / 0.14)`, color: tone, boxShadow: `inset 0 0 0 1px oklch(from ${tone} l c h / 0.32)`, }} diff --git a/clients/dashboard/src/components/layout/mobile-nav.tsx b/clients/dashboard/src/components/layout/mobile-nav.tsx index 0192e677b4..5ac6f81630 100644 --- a/clients/dashboard/src/components/layout/mobile-nav.tsx +++ b/clients/dashboard/src/components/layout/mobile-nav.tsx @@ -116,7 +116,7 @@ export function MobileNavRoot() {

      - v0.1 · console + v0.1 · dashboard

      diff --git a/clients/dashboard/src/components/layout/sidebar.tsx b/clients/dashboard/src/components/layout/sidebar.tsx index 931791d6d1..0bdf8c9921 100644 --- a/clients/dashboard/src/components/layout/sidebar.tsx +++ b/clients/dashboard/src/components/layout/sidebar.tsx @@ -102,7 +102,7 @@ export function Sidebar() { fullstackhero
      - Console + Dashboard
      )} @@ -158,7 +158,7 @@ export function Sidebar() { ) : (

      - v0.1 · console + v0.1 · dashboard

      )}
      From fd6707888d3f24cc575782b866ed057f5c2818f6 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 05:15:00 +0530 Subject: [PATCH 15/15] fix(multitenancy): enforce tenant deactivation (block login + requests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deactivating a tenant only flipped AppTenantInfo.IsActive in the store — nothing in the auth or request pipeline checked it, so a deactivated tenant's users could still log in and call the API. - Add a post-auth guard in MultitenancyModule.ConfigureMiddleware that 403s any request whose resolved tenant is non-root and inactive. It runs before every endpoint, so it covers the anonymous login/refresh requests too. Operators (JWT tenant claim == root) are exempt so they can still manage/reactivate deactivated tenants cross-tenant. - Refresh the distributed-cache store on activate/deactivate. Finbuckle resolves tenants from a 60-min cache, and TenantService only wrote the EF store, so the guard would otherwise keep seeing the stale "active" copy for up to an hour. - Add the missing regression test: TenantActivationTests only asserted the activation endpoint returned 200, never that access was denied. New test asserts a login attempt is not-403 while active and 403 once deactivated. Scope: enforces IsActive (deactivation). ValidUpto/subscription-expiry is intentionally not enforced here to avoid surprise lockouts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MultitenancyModule.cs | 40 +++++++++++++++ .../Services/TenantService.cs | 20 ++++++++ .../Multitenancy/TenantActivationTests.cs | 50 +++++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs index 13924065dd..06c74ad78e 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs @@ -5,6 +5,7 @@ using Finbuckle.MultiTenant.EntityFrameworkCore.Stores; using Finbuckle.MultiTenant.Extensions; using Finbuckle.MultiTenant.Stores; +using FSH.Framework.Core.Exceptions; using FSH.Framework.Persistence; using FSH.Framework.Shared.Constants; using FSH.Framework.Shared.Multitenancy; @@ -148,6 +149,45 @@ public void ConfigureMiddleware(IApplicationBuilder app) } await next(ctx).ConfigureAwait(false); }); + + // ── Deactivated-tenant guard ─────────────────────────────────── + // Deactivation was previously a data-only flag with no enforcement: + // a deactivated tenant's users could still log in and call the API + // (the token handler never checked it, and Finbuckle resolves inactive + // tenants like any other). This post-auth guard — running before every + // endpoint, so it also covers the anonymous login/refresh requests — + // rejects any request whose resolved tenant is non-root and inactive. + // + // Operators (callers whose JWT tenant claim is "root") are exempt so + // they can still manage/reactivate or inspect a deactivated tenant + // cross-tenant (incl. via the root-operator override above). + app.Use(async (ctx, next) => + { + var callerTenant = ctx.User?.FindFirstValue(ClaimConstants.Tenant); + var isOperator = string.Equals(callerTenant, MultitenancyConstants.Root.Id, StringComparison.Ordinal); + if (!isOperator) + { + var accessor = ctx.RequestServices.GetRequiredService>(); + var tenant = accessor.MultiTenantContext?.TenantInfo; + + // Finbuckle's claim strategy no-ops pre-auth, so an authenticated + // request carrying its tenant only in the JWT (no header) may have + // no resolved tenant here — fall back to the caller's claim. + if (tenant is null && !string.IsNullOrEmpty(callerTenant)) + { + var store = ctx.RequestServices.GetRequiredService>(); + tenant = await store.GetAsync(callerTenant).ConfigureAwait(false); + } + + if (tenant is { IsActive: false } && + !string.Equals(tenant.Id, MultitenancyConstants.Root.Id, StringComparison.Ordinal)) + { + throw new ForbiddenException("This tenant has been deactivated. Contact your administrator."); + } + } + + await next(ctx).ConfigureAwait(false); + }); } public void MapEndpoints(IEndpointRouteBuilder endpoints) diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs index ddff37a99b..2382b76369 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs @@ -1,4 +1,5 @@ using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.Stores; using FSH.Framework.Core.Exceptions; using FSH.Framework.Persistence; using FSH.Framework.Shared.Multitenancy; @@ -52,6 +53,7 @@ public async Task ActivateAsync(string id, CancellationToken cancellatio tenant.Activate(); await _tenantStore.UpdateAsync(tenant).ConfigureAwait(false); + await RefreshTenantCacheAsync(tenant).ConfigureAwait(false); return $"tenant {id} is now activated"; } @@ -119,6 +121,7 @@ public async Task DeactivateAsync(string id, CancellationToken cancellat tenant.Deactivate(); await _tenantStore.UpdateAsync(tenant).ConfigureAwait(false); + await RefreshTenantCacheAsync(tenant).ConfigureAwait(false); return $"tenant {id} is now deactivated"; } @@ -174,4 +177,21 @@ public async Task UpgradeSubscriptionAsync(string id, DateTime extende private async Task GetTenantInfoAsync(string id, CancellationToken cancellationToken = default) => await _tenantStore.GetAsync(id).ConfigureAwait(false) ?? throw new NotFoundException($"{typeof(AppTenantInfo).Name} {id} Not Found."); + + // Finbuckle resolves tenants through the distributed-cache store first + // (60-min TTL), and the injected store above only writes to the EF store — + // so an IsActive flip wouldn't take effect until the cache expired, and the + // deactivated-tenant guard would keep seeing the stale "active" copy. Push + // the new state straight into the cache store so the guard (and tenant + // resolution everywhere) sees it on the very next request. + private async Task RefreshTenantCacheAsync(AppTenantInfo tenant) + { + var cacheStore = _serviceProvider + .GetServices>() + .FirstOrDefault(s => s.GetType() == typeof(DistributedCacheStore)); + if (cacheStore is not null) + { + await cacheStore.UpdateAsync(tenant).ConfigureAwait(false); + } + } } \ No newline at end of file diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantActivationTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantActivationTests.cs index b919d54daf..101997e44d 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantActivationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantActivationTests.cs @@ -5,10 +5,12 @@ namespace Integration.Tests.Tests.Multitenancy; [Collection(FshCollectionDefinition.Name)] public sealed class TenantActivationTests { + private readonly FshWebApplicationFactory _factory; private readonly AuthHelper _auth; public TenantActivationTests(FshWebApplicationFactory factory) { + _factory = factory; _auth = new AuthHelper(factory); } @@ -68,4 +70,52 @@ public async Task ChangeTenantActivation_Should_DeactivateAndReactivate_When_Ten // Both should succeed if provisioning completed, otherwise at least one (deactivateResponse.IsSuccessStatusCode || reactivateResponse.IsSuccessStatusCode).ShouldBeTrue(); } + + [Fact] + public async Task DeactivatedTenant_Should_Be_Denied_At_Login() + { + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + var tenantId = $"blocked-{uniqueId}"; + + var createResponse = await adminClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new + { + id = tenantId, + name = $"Blocked Tenant {uniqueId}", + connectionString = (string?)null, + adminEmail = $"blocked-{uniqueId}@tenant.com", + adminPassword = TestConstants.DefaultPassword, + issuer = "blocked.issuer" + }); + createResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + // While active, a login attempt reaches the token handler (and fails on + // credentials) — the tenant guard does NOT short-circuit it. + (await TryIssueTokenAsync(tenantId)).ShouldNotBe(HttpStatusCode.Forbidden); + + var deactivate = await adminClient.PostAsJsonAsync( + $"{TestConstants.TenantsBasePath}/{tenantId}/activation", + new { tenantId, isActive = false }); + deactivate.StatusCode.ShouldBe(HttpStatusCode.OK); + + // Once deactivated, the tenant guard rejects the request before the + // token handler runs → 403. Regression guard for "a deactivated tenant + // can still log in via the dashboard". + (await TryIssueTokenAsync(tenantId)).ShouldBe(HttpStatusCode.Forbidden); + } + + // Anonymous token request scoped to a tenant via the `tenant` header, with + // deliberately invalid credentials. We assert on the status the pipeline + // returns (401 when the handler runs, 403 when the tenant guard blocks it), + // so the test never depends on the tenant's admin being provisioned yet. + private async Task TryIssueTokenAsync(string tenantId) + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage( + HttpMethod.Post, $"{TestConstants.IdentityBasePath}/token/issue"); + request.Headers.Add("tenant", tenantId); + request.Content = JsonContent.Create(new { email = "nobody@example.com", password = "Wrong-Password-1!" }); + using var response = await client.SendAsync(request); + return response.StatusCode; + } }