Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3829c96
docs(spec): admin → dashboard design unification program
iammukeshm May 27, 2026
b643e0f
fix(chat): deliver live messages to channels joined after connect
iammukeshm May 27, 2026
0d0d8e6
feat(admin): phase 1 — adopt dashboard design tokens & global styles
iammukeshm May 27, 2026
17183bf
feat(admin): phase 2 — UI primitives, section/field layout, presigned…
iammukeshm May 27, 2026
5c1ce68
feat(admin): phase 3 — rebuild app shell on the dashboard pattern
iammukeshm May 27, 2026
d60ba0f
fix(chat): reposition hover rail, portal emoji picker, add pinned bar
iammukeshm May 27, 2026
064bbe3
feat(admin): phase 4 — migrate all pages to the dashboard vocabulary
iammukeshm May 27, 2026
4d34a42
feat(admin): login demo-account dialog + modernized tenant detail
iammukeshm May 27, 2026
6c15e6f
feat(admin): new-tenant creation as a modern dialog
iammukeshm May 27, 2026
db6b5a7
feat(admin): audit detail as a sheet + modern filter select
iammukeshm May 27, 2026
c801709
feat(admin): modernize tenant-detail cards + settings layout parity
iammukeshm May 27, 2026
10c3fa4
fix(admin): health check rows expand reliably (controlled disclosure)
iammukeshm May 27, 2026
6e550f7
feat(admin): user + role creation as modern dialogs
iammukeshm May 27, 2026
34a28e7
chore(dashboard): flatten impersonation banner gradient + rename "Con…
iammukeshm May 27, 2026
9f3348f
Merge remote-tracking branch 'origin/main' into feat/admin-dashboard-…
iammukeshm May 27, 2026
fd67078
fix(multitenancy): enforce tenant deactivation (block login + requests)
iammukeshm May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/rules/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion clients/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=JetBrains+Mono:wght@400;500;600&display=swap"
href="https://fonts.googleapis.com/css2?family=Figtree:wght@300..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Outfit:wght@100..900&display=swap"
/>
<title>FullStackHero — Admin</title>
</head>
Expand Down
138 changes: 138 additions & 0 deletions clients/admin/src/api/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { apiFetch } from "@/lib/api-client";
import type { PagedResponse } from "@/lib/api-types";

// Mirrors FSH.Modules.Files.Domain.Visibility — Public/Private numeric codes
// match the server's int? Visibility shape on the FileAssetDto.
export const Visibility = {
Public: 0,
Private: 1,
} as const;
export type VisibilityValue = (typeof Visibility)[keyof typeof Visibility];

// Mirrors FSH.Modules.Files.Domain.FileAssetStatus.
export const FileAssetStatus = {
PendingUpload: 0,
Available: 1,
Quarantined: 2,
} as const;
export type FileAssetStatusValue = (typeof FileAssetStatus)[keyof typeof FileAssetStatus];

// Mirrors FSH.Modules.Files.Contracts.v1.DTOs.FileAssetDto.
export type FileAssetDto = {
id: string;
ownerType: string;
ownerId?: string | null;
originalFileName: string;
contentType: string;
sizeBytes: number;
visibility: VisibilityValue;
status: FileAssetStatusValue;
scanStatus: number;
createdAtUtc: string;
publicUrl?: string | null;
/** The user that uploaded the file. Use with useUserDisplay to resolve a name.
* Older server versions before the field was added send "" — guard against it
* when deciding whether to render an "uploaded by" attribution row. */
createdByUserId: string;
deletedOnUtc?: string | null;
deletedBy?: string | null;
};

export type PresignedUploadResponse = {
fileAssetId: string;
uploadUrl: string;
requiredHeaders: Record<string, string>;
expiresAt: string;
};

export type PresignedDownloadResponse = {
url: string;
expiresAt: string;
};

export type RequestUploadUrlInput = {
ownerType: string;
ownerId?: string | null;
fileName: string;
contentType: string;
sizeBytes: number;
visibility: VisibilityValue;
category: string;
};

export function requestUploadUrl(input: RequestUploadUrlInput): Promise<PresignedUploadResponse> {
return apiFetch<PresignedUploadResponse>("/api/v1/files/upload-url", {
method: "POST",
body: JSON.stringify(input),
});
}

export function finalizeUpload(fileAssetId: string): Promise<FileAssetDto> {
return apiFetch<FileAssetDto>(
`/api/v1/files/${encodeURIComponent(fileAssetId)}/finalize`,
{ method: "POST" },
);
}

export function getFileMetadata(fileAssetId: string): Promise<FileAssetDto> {
return apiFetch<FileAssetDto>(`/api/v1/files/${encodeURIComponent(fileAssetId)}`);
}

export function getFileDownloadUrl(
fileAssetId: string,
options: { inline?: boolean } = {},
): Promise<PresignedDownloadResponse> {
const qs = options.inline ? "?inline=true" : "";
return apiFetch<PresignedDownloadResponse>(
`/api/v1/files/${encodeURIComponent(fileAssetId)}/url${qs}`,
);
}

export function listMyFiles(page = 1, pageSize = 20): Promise<FileAssetDto[]> {
return apiFetch<FileAssetDto[]>(
`/api/v1/files/mine?page=${page}&pageSize=${pageSize}`,
);
}

export function listSharedFiles(page = 1, pageSize = 20): Promise<FileAssetDto[]> {
return apiFetch<FileAssetDto[]>(
`/api/v1/files/shared?page=${page}&pageSize=${pageSize}`,
);
}

/** Flip a file's visibility. Server returns the refreshed DTO so the client can patch
* its preview/list without a follow-up GET. */
export function changeFileVisibility(
fileAssetId: string,
visibility: VisibilityValue,
): Promise<FileAssetDto> {
return apiFetch<FileAssetDto>(
`/api/v1/files/${encodeURIComponent(fileAssetId)}/visibility`,
{
method: "PATCH",
body: JSON.stringify({ visibility }),
},
);
}

export function deleteFile(fileAssetId: string): Promise<void> {
return apiFetch<void>(`/api/v1/files/${encodeURIComponent(fileAssetId)}`, {
method: "DELETE",
});
}

export function listTrashedFiles(
pageNumber = 1,
pageSize = 20,
): Promise<PagedResponse<FileAssetDto>> {
return apiFetch<PagedResponse<FileAssetDto>>(
`/api/v1/files/trash?pageNumber=${pageNumber}&pageSize=${pageSize}`,
);
}

export function restoreFile(fileAssetId: string): Promise<void> {
return apiFetch<void>(
`/api/v1/files/${encodeURIComponent(fileAssetId)}/restore`,
{ method: "POST" },
);
}
173 changes: 173 additions & 0 deletions clients/admin/src/components/auth/demo-accounts-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { useEffect } from "react";
import { ArrowUpRight, ShieldCheck } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { ADMIN_DEMO_ACCOUNTS, type DemoAccount } from "@/pages/login.demo-accounts";

// ────────────────────────────────────────────────────────────────────────
// DemoAccountsDialog — dev-only demo account picker for the admin app.
//
// Admin surfaces a single root/superadmin account (vs. the dashboard's
// multi-tenant tenant-picker). The layout is a single-pane account list
// rather than the dashboard's two-pane tenant-rail, since there's only
// one operator tenant. Tapping an account signs in instantly and closes
// the dialog. Gating (import.meta.env.DEV) is the caller's responsibility.
// ────────────────────────────────────────────────────────────────────────

interface DemoAccountsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Fired when an account row is tapped — caller signs in with these creds. */
onPick: (account: DemoAccount) => void;
}

export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsDialogProps) {
// Nothing to reset on re-open (single account list, no tenant rail).
useEffect(() => {}, [open]);

const handlePick = (account: DemoAccount) => {
onOpenChange(false);
onPick(account);
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden rounded-2xl border-border/70 p-0 sm:max-w-[480px]">
<DialogTitle className="sr-only">Demo accounts</DialogTitle>
<DialogDescription className="sr-only">
Pick a demo account to sign in to the admin console.
</DialogDescription>

{/* Atmospheric gradient wash */}
<div
className="pointer-events-none absolute -top-32 -right-32 size-[320px] rounded-full bg-[radial-gradient(closest-side,color-mix(in_srgb,var(--color-primary)_14%,transparent),transparent)] blur-3xl"
aria-hidden
/>
<div
className="pointer-events-none absolute -bottom-24 -left-24 size-[240px] rounded-full bg-[radial-gradient(closest-side,color-mix(in_srgb,var(--color-accent-signal,var(--color-primary))_10%,transparent),transparent)] blur-3xl"
aria-hidden
/>

{/* Header */}
<header className="fsh-enter relative px-7 pt-7 pb-5">
<div className="mb-2.5 flex items-center gap-2">
<span className="relative flex size-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-primary)] opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-[var(--color-primary)]" />
</span>
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.22em] text-[oklch(from_var(--color-primary)_l_c_h_/_0.85)]">
Dev · demo
</span>
</div>
<h2 className="font-display text-[20px] font-semibold leading-[1.15] tracking-[-0.01em] text-[var(--color-foreground)]">
Sign in as operator.
</h2>
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--color-muted-foreground)]/80">
Tap an account below — we'll fill the credentials and sign you in instantly.
</p>
</header>

{/* Account list */}
<div className="relative border-t border-[var(--color-border)]/60 bg-[var(--color-background)]/30 px-4 py-3">
<div className="mb-2 flex items-baseline gap-2 px-2">
<span className="font-mono text-[9.5px] font-semibold uppercase tabular-nums tracking-[0.18em] text-[oklch(from_var(--color-primary)_l_c_h_/_0.55)]">
Operator accounts
</span>
<div className="relative top-[-2px] h-px flex-1 bg-[var(--color-border)]/70" />
<span className="font-mono text-[9.5px] uppercase tracking-[0.15em] text-[var(--color-muted-foreground)]/50">
tap to sign in
</span>
</div>

<div className="space-y-0.5">
{ADMIN_DEMO_ACCOUNTS.map((account, i) => (
<AccountRow
key={account.email}
account={account}
delay={i * 40}
onPick={handlePick}
/>
))}
</div>
</div>

{/* Footer */}
<div className="relative flex items-center justify-between border-t border-[var(--color-border)]/60 bg-[var(--color-background)]/40 px-7 py-3">
<p className="flex items-center gap-1.5 text-[10.5px] text-[var(--color-muted-foreground)]/60">
<ShieldCheck className="size-3 shrink-0" />
<span>
<span className="font-mono text-[var(--color-muted-foreground)]/80">dev only</span>
<span className="mx-2 text-[var(--color-muted-foreground)]/30">·</span>
Not visible in production.
</span>
</p>
<kbd className="hidden items-center gap-1 rounded border border-[var(--color-border)]/60 bg-[var(--color-background)]/60 px-1.5 py-0.5 font-mono text-[9.5px] text-[var(--color-muted-foreground)]/60 sm:inline-flex">
esc
</kbd>
</div>
</DialogContent>
</Dialog>
);
}

// ─── AccountRow ──────────────────────────────────────────────────────────────

function AccountRow({
account,
delay,
onPick,
}: {
account: DemoAccount;
delay: number;
onPick: (account: DemoAccount) => void;
}) {
return (
<button
type="button"
onClick={() => onPick(account)}
style={{ animationDelay: `${delay}ms` }}
className="fsh-enter group relative flex w-full cursor-pointer items-center gap-3 overflow-hidden rounded-lg px-2 py-2.5 text-left outline-none transition-all duration-200 hover:translate-x-0.5 focus-visible:bg-[var(--color-primary)]/[0.04] active:scale-[0.99]"
>
{/* Hover gradient wash */}
<span
className="pointer-events-none absolute inset-0 rounded-lg bg-[linear-gradient(90deg,color-mix(in_srgb,var(--color-primary)_9%,transparent),transparent_70%)] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
aria-hidden
/>

{/* Avatar */}
<span className="relative flex size-9 shrink-0 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-background)] font-mono text-[11px] font-semibold text-[var(--color-muted-foreground)]/80 transition-all duration-300 group-hover:border-[var(--color-primary)] group-hover:bg-[var(--color-primary)] group-hover:text-[var(--color-primary-foreground)] group-hover:shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-primary)_18%,transparent)]">
{account.initials}
</span>

{/* Content */}
<div className="relative min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-[13px] font-semibold leading-tight tracking-tight text-[var(--color-foreground)]">
{account.label}
</span>
<span className="rounded bg-[var(--color-muted)] px-1.5 py-0.5 font-mono text-[9.5px] uppercase tracking-wider text-[var(--color-muted-foreground)]/70 transition-colors duration-200 group-hover:text-[var(--color-primary)]/80">
{account.tenant}
</span>
</div>
<div className="mt-0.5 truncate font-mono text-[10.5px] leading-tight text-[var(--color-muted-foreground)]/55">
{account.email}
</div>
<div className="mt-0.5 text-[11px] italic leading-tight text-[var(--color-muted-foreground)]/60">
{account.persona}
</div>
</div>

{/* Arrow indicator */}
<span
className="relative flex size-6 shrink-0 -translate-x-1 items-center justify-center rounded-md text-[var(--color-muted-foreground)]/30 opacity-0 transition-all duration-300 group-hover:translate-x-0 group-hover:bg-[var(--color-primary)]/10 group-hover:text-[var(--color-primary)] group-hover:opacity-100"
aria-hidden
>
<ArrowUpRight className="size-3.5" strokeWidth={2.25} />
</span>
</button>
);
}
Loading
Loading