admin: completeness + UI consistency pass

Arcadia wiring:
- home: real Overview dashboard (tenants/users/audit/health probe) replacing the inherited Vibespace welcome tiles; skeleton loaders, refresh button, registers admin context
- profile: split into Account (synced via getUser/updateUser of session user) and local Preferences; updateSessionUser keeps the appbar in sync after edits
- session: drop unused signIn mock, add updateSessionUser, refresh tests
- profile schema: drop redundant Profile.name/email (session is the source of truth)
- routes: delete orphaned resources route + lib

Auth flows that previously 404'd:
- /signup, /login/forgot, /login/reset, /login/2fa wired via @crema/arcadia-auth-ui
- shared AuthShell + AuthBrand wrapper

Assistant tools (admin-tools.ts):
- +10 tools: deactivate_tenant, set_user_status, delete_user, list_memberships, list_roles, revoke_api_key, create_user, update_user, assign_role, remove_role
- list_memberships gains user_id filter for "tenants this user belongs to" queries
- search_kb / read_chunk: new token resolution (window override → VITE_ARCADIA_SEARCH_TOKEN service token → operator session JWT → "dev"); on 401/403 emit a tailored hint based on which token was used

UI consistency:
- new PageHeader component
- AppShell.title was unrendered — dropped; first-child padding on #main-content keeps the floating actions pill from colliding with header content
- removed dead "Sign in required" fallback cards from 14 routes (AppShell already redirects)
- stripped p-6 from outer wrappers across 14 routes (was double-padding under AppShell's own p-6)
- migrated home + tenants to PageHeader

arcadia-search ergonomics:
- scripts/mint-search-token.mjs + `npm run mint:search-token` mints HS512 JWT with required tenant_id claim, upserts VITE_ARCADIA_SEARCH_TOKEN into .env.local
- README/.env document the new VITE_ARCADIA_SEARCH_URL / VITE_ARCADIA_SEARCH_TOKEN knobs
- .env.local now gitignored

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-04 15:37:31 +10:00
parent 444516e900
commit 20c592dfa7
44 changed files with 1594 additions and 984 deletions

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import { Activity, Eye, RefreshCw } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
@@ -201,29 +200,9 @@ export default function ActivityRoute() {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Audit log">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>The audit log requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/activity">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Audit log">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>

View File

@@ -623,7 +623,7 @@ export default function AIRoute() {
const availableModels = status.kind === "live" ? status.models : ["mock"]
return (
<AppShell title="AI">
<AppShell>
<LLMProvider adapter={adapter} model={activeModel}>
{/* Console aesthetic is scoped to this wrapper only, so the appbar
* and sidebar keep using the global skyrise tokens (light/dark

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
Megaphone,
@@ -232,29 +231,9 @@ export default function AnnouncementsRoute() {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Announcements">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Announcements require an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/announcements">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Announcements">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Announcements</h1>

View File

@@ -233,13 +233,13 @@ const mockAdapter = new MockLLM({
},
{
matches: (req) =>
/(take me to|open|navigate|go to).*(resources|library|settings|activity|assistant|overview|home)/i.test(
/(take me to|open|navigate|go to).*(tenants|users|library|settings|activity|assistant|overview|home)/i.test(
req.messages.at(-1)?.content ?? "",
),
response: [
"On it.\n\n",
"```action\n",
"navigate /resources\n",
"navigate /tenants\n",
"```\n",
],
},
@@ -411,7 +411,7 @@ export default function AssistantRoute() {
status.kind === "live" ? model || status.models[0] : "mock"
return (
<AppShell title="Assistant">
<AppShell>
<LLMProvider adapter={adapter} model={activeModel}>
<AssistantSurface
key={`${activeThreadId}-${compactNonce}`}

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
ArrowLeft,
Boxes,
@@ -197,29 +196,9 @@ export default function BucketsRoute() {
)
useRegisterAdminContext("buckets", summary)
if (!session) {
return (
<AppShell title="Buckets">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Bucket administration requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/buckets">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Buckets">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">

View File

@@ -1,7 +1,23 @@
import { ArrowRight, Sparkles, Boxes, Activity, BookOpen } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
Activity,
AlertTriangle,
Building2,
CheckCircle2,
CircleAlert,
HeartPulse,
RefreshCw,
Users as UsersIcon,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { AlertBanner } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { Button } from "~/components/ui/button"
import { Skeleton } from "~/components/ui/skeleton"
import {
Card,
CardContent,
@@ -9,88 +25,406 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { listAuditLogs, type AuditLog } from "~/lib/arcadia/audit-logs"
import {
getHealth,
SUBSYSTEMS,
type HealthStatus,
type HealthSubsystem,
type OverallHealth,
} from "~/lib/arcadia/health"
import { listTenants, type Tenant } from "~/lib/arcadia/tenants"
import { listUsers, type User } from "~/lib/arcadia/users"
import { useRegisterAdminContext } from "~/lib/admin-context"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Overview")
const tiles = [
{
to: "/assistant",
icon: Sparkles,
title: "Assistant",
body: "AI-first surface — chat, suggestions, and full UI control.",
accent: true,
},
{
to: "/resources",
icon: Boxes,
title: "Resources",
body: "Traditional list + detail surface for managed entities.",
},
{
to: "/activity",
icon: Activity,
title: "Activity",
body: "Event stream and audit log.",
},
{
to: "/library",
icon: BookOpen,
title: "Library",
body: "Saved items, templates, reusable artifacts.",
},
]
interface DashboardData {
tenants: Tenant[]
users: User[]
audit: AuditLog[]
health: OverallHealth | null
}
const EMPTY: DashboardData = { tenants: [], users: [], audit: [], health: null }
export default function HomeRoute() {
return (
<AppShell title="Overview">
<Card>
<CardHeader>
<CardTitle>Welcome</CardTitle>
<CardDescription>
A hybrid traditional + AI-first scaffold. Use the rail to navigate;
the Assistant can drive the UI on your behalf try{" "}
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
P
</kbd>{" "}
for the script runner.
</CardDescription>
</CardHeader>
</Card>
const session = useSession()
const arcadia = useArcadiaClient()
<div className="grid gap-4 md:grid-cols-2">
{tiles.map((t) => {
const Icon = t.icon
return (
const [data, setData] = useState<DashboardData>(EMPTY)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
const [tenants, users, audit, health] = await Promise.all([
listTenants(arcadia).catch((err) => {
throw err
}),
listUsers(arcadia),
listAuditLogs(arcadia, { limit: 10 }),
getHealth(arcadia).catch(() => null),
]).catch((err) => {
setError(err instanceof ArcadiaError ? err.message : "Failed to load overview.")
return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null]
})
setData({ tenants, users, audit, health })
setRefreshedAt(new Date())
setLoading(false)
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const stats = useMemo(() => {
const activeTenants = data.tenants.filter((t) => t.status === "active").length
const activeUsers = data.users.filter((u) => u.status === "active").length
const errorEvents = data.audit.filter(
(a) => a.severity === "error" || a.severity === "critical",
).length
return {
tenants: { total: data.tenants.length, active: activeTenants },
users: { total: data.users.length, active: activeUsers },
audit: { recent: data.audit.length, errors: errorEvents },
health: data.health?.status ?? "unconfigured",
}
}, [data])
useRegisterAdminContext("overview", stats)
return (
<AppShell>
<PageHeader
title="Overview"
description={
<>
Live snapshot of the platform tenants, users, recent activity, and health.
{refreshedAt ? (
<>
{" "}
Refreshed {refreshedAt.toLocaleTimeString()}.
</>
) : null}
</>
}
actions={
<Button
data-action="overview-refresh"
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
>
<RefreshCw className={loading ? "size-4 animate-spin" : "size-4"} />
Refresh
</Button>
}
/>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatTile
to="/tenants"
dataAction="overview-tile-tenants"
icon={Building2}
label="Tenants"
value={stats.tenants.total}
sub={`${stats.tenants.active} active`}
loading={loading}
/>
<StatTile
to="/users"
dataAction="overview-tile-users"
icon={UsersIcon}
label="Users"
value={stats.users.total}
sub={`${stats.users.active} active`}
loading={loading}
/>
<StatTile
to="/activity"
dataAction="overview-tile-activity"
icon={Activity}
label="Recent events"
value={stats.audit.recent}
sub={
stats.audit.errors > 0
? `${stats.audit.errors} error${stats.audit.errors === 1 ? "" : "s"}`
: "no errors"
}
loading={loading}
tone={stats.audit.errors > 0 ? "warning" : "default"}
/>
<StatTile
to="/monitoring"
dataAction="overview-tile-health"
icon={HeartPulse}
label="Platform health"
value={statusLabel(stats.health)}
sub={data.health ? `as of ${new Date(data.health.checked_at).toLocaleTimeString()}` : "unreachable"}
loading={loading}
tone={statusTone(stats.health)}
/>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="flex-row items-center justify-between gap-2">
<div>
<CardTitle>Recent activity</CardTitle>
<CardDescription>
Latest audit events across the platform.
</CardDescription>
</div>
<Link
key={t.to}
to={t.to}
data-action={`home-tile-${t.title.toLowerCase()}`}
className="group block"
to="/activity"
data-action="overview-activity-all"
className="text-xs font-medium text-muted-foreground hover:text-foreground"
>
<Card
className={[
"h-full transition-colors",
t.accent
? "border-primary/30 bg-primary/5 hover:border-primary/50"
: "hover:border-foreground/20",
].join(" ")}
>
<CardHeader>
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-5" />
</div>
<CardTitle className="flex items-center gap-2">
{t.title}
<ArrowRight className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
</CardTitle>
<CardDescription>{t.body}</CardDescription>
</CardHeader>
</Card>
View all
</Link>
)
})}
</CardHeader>
<CardContent>
<RecentActivity logs={data.audit} loading={loading} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Subsystems</CardTitle>
<CardDescription>
Live probe of each platform subsystem.
</CardDescription>
</CardHeader>
<CardContent>
<SubsystemList health={data.health} loading={loading} />
</CardContent>
</Card>
</div>
</AppShell>
)
}
function StatTile({
to,
dataAction,
icon: Icon,
label,
value,
sub,
loading,
tone = "default",
}: {
to: string
dataAction: string
icon: React.ComponentType<{ className?: string }>
label: string
value: number | string
sub: string
loading: boolean
tone?: "default" | "warning" | "error" | "ok"
}) {
const accent =
tone === "error"
? "border-destructive/40 bg-destructive/5"
: tone === "warning"
? "border-amber-500/40 bg-amber-500/5"
: tone === "ok"
? "border-emerald-500/40 bg-emerald-500/5"
: ""
return (
<Link
to={to}
data-action={dataAction}
className="group block focus:outline-none"
>
<Card className={`h-full transition-colors hover:border-foreground/20 ${accent}`}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
<Icon className="size-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{loading ? (
<Skeleton className="h-9 w-20" />
) : (
<span className="text-3xl font-semibold tabular-nums">{value}</span>
)}
{loading ? (
<Skeleton className="h-3 w-24" />
) : (
<span className="text-xs text-muted-foreground">{sub}</span>
)}
</CardContent>
</Card>
</Link>
)
}
function RecentActivity({ logs, loading }: { logs: AuditLog[]; loading: boolean }) {
if (loading && logs.length === 0) {
return (
<p className="py-4 text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
)
}
if (logs.length === 0) {
return (
<p className="py-4 text-sm text-muted-foreground">No recent events.</p>
)
}
return (
<ul className="flex flex-col divide-y">
{logs.slice(0, 8).map((l) => (
<li
key={l.id}
className="flex items-start justify-between gap-3 py-2.5 text-sm"
>
<div className="flex min-w-0 flex-col">
<span className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{l.action}
</code>
<span className="truncate text-xs text-muted-foreground">
{l.resource_type}
{l.resource_id ? ` · ${l.resource_id.slice(0, 8)}` : ""}
</span>
</span>
<span className="truncate text-xs text-muted-foreground">
{l.user?.email ?? "system"}
</span>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<SeverityDot severity={l.severity} />
<time
className="text-[11px] text-muted-foreground"
dateTime={l.inserted_at}
>
{timeAgo(l.inserted_at)}
</time>
</div>
</li>
))}
</ul>
)
}
function SubsystemList({
health,
loading,
}: {
health: OverallHealth | null
loading: boolean
}) {
if (loading && !health) {
return (
<p className="py-4 text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Probing
</p>
)
}
if (!health) {
return (
<p className="py-4 text-sm text-muted-foreground">
Health endpoint unreachable.
</p>
)
}
return (
<ul className="flex flex-col divide-y">
{SUBSYSTEMS.map((sys) => {
const sub = health.subsystems[sys]
return (
<li
key={sys}
className="flex items-center justify-between gap-3 py-2.5 text-sm"
>
<span className="font-medium capitalize">{labelFor(sys)}</span>
<span className="flex items-center gap-2">
<StatusIcon status={sub?.status ?? "unconfigured"} />
<span className="text-xs text-muted-foreground">
{sub?.message ?? statusLabel(sub?.status ?? "unconfigured")}
</span>
</span>
</li>
)
})}
</ul>
)
}
function SeverityDot({ severity }: { severity: string }) {
const tone =
severity === "critical" || severity === "error"
? "bg-destructive"
: severity === "warning"
? "bg-amber-500"
: "bg-emerald-500"
return (
<span
aria-label={severity}
title={severity}
className={`size-2 rounded-full ${tone}`}
/>
)
}
function StatusIcon({ status }: { status: HealthStatus }) {
if (status === "ok")
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />
if (status === "degraded")
return <AlertTriangle className="size-4 text-amber-500" aria-label="degraded" />
if (status === "error")
return <CircleAlert className="size-4 text-destructive" aria-label="error" />
return <CircleAlert className="size-4 text-muted-foreground" aria-label="unconfigured" />
}
function labelFor(sys: HealthSubsystem): string {
if (sys === "api") return "API"
if (sys === "db") return "Database"
return sys
}
function statusLabel(status: HealthStatus | string): string {
if (status === "ok") return "Healthy"
if (status === "degraded") return "Degraded"
if (status === "error") return "Down"
return "Unknown"
}
function statusTone(status: HealthStatus | string): "default" | "ok" | "warning" | "error" {
if (status === "ok") return "ok"
if (status === "degraded") return "warning"
if (status === "error") return "error"
return "default"
}
function timeAgo(iso: string): string {
const t = new Date(iso).getTime()
if (Number.isNaN(t)) return ""
const diff = Date.now() - t
const sec = Math.round(diff / 1000)
if (sec < 60) return `${sec}s ago`
const min = Math.round(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.round(min / 60)
if (hr < 24) return `${hr}h ago`
const d = Math.round(hr / 24)
return `${d}d ago`
}

View File

@@ -38,7 +38,7 @@ export default function LibraryRoute() {
const open = items.find((x) => x.id === openId) ?? null
return (
<AppShell title="Library">
<AppShell>
<Card>
<CardHeader>
<CardTitle>Library</CardTitle>

61
app/routes/login.2fa.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { useState } from "react"
import { useNavigate, useSearchParams } from "react-router"
import { TwoFactorChallengeForm } from "@crema/arcadia-auth-ui"
import { pageTitle } from "~/lib/page-meta"
import { persistFromArcadiaLogin } from "~/lib/session"
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
export const meta = () => pageTitle("Two-factor verification")
export default function TwoFactorRoute() {
const [params] = useSearchParams()
const navigate = useNavigate()
const challenge = params.get("challenge") ?? ""
const next = params.get("next") || "/"
const [mode, setMode] = useState<"totp" | "recovery">("totp")
if (!challenge) {
return (
<AuthShell>
<div
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
style={{ borderColor: "var(--border)" }}
>
<h1 className="text-base font-semibold">Challenge missing</h1>
<p className="text-muted-foreground">
This page is only reachable after a sign-in attempt. Start over.
</p>
<button
type="button"
onClick={() => navigate("/login")}
className="mt-2 text-xs font-medium text-primary hover:underline"
data-action="2fa-back-to-login"
>
Back to sign in
</button>
</div>
</AuthShell>
)
}
return (
<AuthShell>
<TwoFactorChallengeForm
brand={<AuthBrand />}
challenge={challenge}
mode={mode}
onUseRecoveryCode={mode === "totp" ? () => setMode("recovery") : undefined}
onBack={
mode === "recovery"
? () => setMode("totp")
: () => navigate("/login")
}
onSuccess={({ tokens, user }) => {
persistFromArcadiaLogin(tokens, user)
navigate(next, { replace: true })
}}
/>
</AuthShell>
)
}

View File

@@ -0,0 +1,50 @@
import { useState } from "react"
import { useNavigate } from "react-router"
import { CheckCircle2 } from "lucide-react"
import { PasswordResetRequestForm } from "@crema/arcadia-auth-ui"
import { pageTitle } from "~/lib/page-meta"
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
export const meta = () => pageTitle("Reset password")
export default function ForgotPasswordRoute() {
const navigate = useNavigate()
const [sentTo, setSentTo] = useState<string | null>(null)
if (sentTo) {
return (
<AuthShell>
<div
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
style={{ borderColor: "var(--border)" }}
>
<CheckCircle2 className="size-8 text-emerald-500" />
<h1 className="text-base font-semibold">Check your email</h1>
<p className="text-muted-foreground">
If an account exists for <strong>{sentTo}</strong>, we've sent a link
to reset your password.
</p>
<button
type="button"
onClick={() => navigate("/login")}
className="mt-2 text-xs font-medium text-primary hover:underline"
data-action="forgot-back-to-login"
>
Back to sign in
</button>
</div>
</AuthShell>
)
}
return (
<AuthShell>
<PasswordResetRequestForm
brand={<AuthBrand />}
onBack={() => navigate("/login")}
onSuccess={(email) => setSentTo(email)}
/>
</AuthShell>
)
}

View File

@@ -0,0 +1,47 @@
import { useNavigate, useSearchParams } from "react-router"
import { PasswordResetConfirmForm } from "@crema/arcadia-auth-ui"
import { pageTitle } from "~/lib/page-meta"
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
export const meta = () => pageTitle("Set new password")
export default function ResetPasswordRoute() {
const [params] = useSearchParams()
const navigate = useNavigate()
const token = params.get("token") ?? ""
if (!token) {
return (
<AuthShell>
<div
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
style={{ borderColor: "var(--border)" }}
>
<h1 className="text-base font-semibold">Reset link invalid</h1>
<p className="text-muted-foreground">
No token in the URL. Request a fresh password reset email.
</p>
<button
type="button"
onClick={() => navigate("/login/forgot")}
className="mt-2 text-xs font-medium text-primary hover:underline"
data-action="reset-request-new"
>
Request a new link
</button>
</div>
</AuthShell>
)
}
return (
<AuthShell>
<PasswordResetConfirmForm
brand={<AuthBrand />}
token={token}
onSuccess={() => navigate("/login?reset=ok", { replace: true })}
/>
</AuthShell>
)
}

View File

@@ -5,6 +5,7 @@ import { LoginForm } from "@crema/arcadia-auth-ui"
import { useBrand } from "~/lib/identity"
import { pageTitle } from "~/lib/page-meta"
import { useSession, persistFromArcadiaLogin } from "~/lib/session"
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
export const meta = () => pageTitle("Sign in")
@@ -13,40 +14,24 @@ export default function LoginRoute() {
const [params] = useSearchParams()
const session = useSession()
const brand = useBrand()
const BrandIcon = brand.icon
const next = params.get("next") || "/"
// Already signed in? Bounce.
useEffect(() => {
if (session) navigate(next, { replace: true })
}, [session, next, navigate])
return (
<div
// Force dark mode on the login page regardless of the operator's
// saved theme preference. Scoped to this wrapper (not documentElement)
// so navigating away after sign-in restores their preferred mode.
className="dark relative isolate flex min-h-svh items-center justify-center p-4"
style={{ background: "var(--background)" }}
>
<AuthShell>
<LoginForm
brand={
<div className="flex items-center gap-2">
<span
className="flex size-8 items-center justify-center rounded-lg"
style={{ background: "var(--primary)", color: "var(--primary-foreground)" }}
>
<BrandIcon className="size-4" />
</span>
<span className="text-sm font-semibold">{brand.name}</span>
</div>
}
brand={<AuthBrand />}
heading={`Sign in to ${brand.name}`}
subhead="Use your arcadia credentials. In dev seeds: admin@example.com / AdminP@ssw0rd."
onSuccess={async ({ tokens, user, twoFactorRequired, twoFactorChallenge }) => {
if (twoFactorRequired && twoFactorChallenge) {
navigate(`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`)
navigate(
`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`,
)
return
}
persistFromArcadiaLogin(tokens, user)
@@ -55,6 +40,6 @@ export default function LoginRoute() {
onForgotPassword={() => navigate("/login/forgot")}
onSignup={() => navigate("/signup")}
/>
</div>
</AuthShell>
)
}

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
Network,
@@ -286,29 +285,9 @@ export default function MembershipsRoute() {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Memberships">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Membership management requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/memberships">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Memberships">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Memberships</h1>

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
Activity,
AlertTriangle,
@@ -196,29 +195,9 @@ export default function MonitoringRoute() {
)
useRegisterAdminContext("monitoring", summary)
if (!session) {
return (
<AppShell title="Monitoring">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Monitoring requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/monitoring">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Monitoring">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Server stats &amp; health</h1>

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
Globe,
@@ -116,29 +115,9 @@ export default function NetworkingRoute() {
droplets: droplets.length,
})
if (!session) {
return (
<AppShell title="Networking">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Networking requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/networking">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Networking">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Networking</h1>

View File

@@ -1,8 +1,12 @@
import { useEffect, useState } from "react"
import { Check, Trash2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { Check, RefreshCw, Trash2 } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { AlertBanner } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Card,
@@ -22,72 +26,290 @@ import { Textarea } from "~/components/ui/textarea"
import { useAgents } from "~/lib/agents"
import { pageTitle } from "~/lib/page-meta"
import {
DEFAULT_PROFILE,
profileInitials,
resetProfile,
saveProfile,
useProfile,
type Profile,
} from "~/lib/profile"
import { getUser, updateUser, type User } from "~/lib/arcadia/users"
import { updateSessionUser, useSession } from "~/lib/session"
export const meta = () => pageTitle("Profile")
interface AccountDraft {
first_name: string
last_name: string
email: string
}
export default function ProfileRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const profile = useProfile()
const agents = useAgents()
const [draft, setDraft] = useState<Profile>(profile)
const [savedAt, setSavedAt] = useState<number | null>(null)
// Local preferences (avatar, title, bio, signature, default agent).
const [prefs, setPrefs] = useState<Profile>(profile)
const [prefsSavedAt, setPrefsSavedAt] = useState<number | null>(null)
useEffect(() => {
setPrefs(profile)
}, [profile])
const prefsDirty = JSON.stringify(prefs) !== JSON.stringify(profile)
// Arcadia account.
const [account, setAccount] = useState<User | null>(null)
const [accountDraft, setAccountDraft] = useState<AccountDraft>({
first_name: "",
last_name: "",
email: "",
})
const [accountLoading, setAccountLoading] = useState(true)
const [accountSaving, setAccountSaving] = useState(false)
const [accountSavedAt, setAccountSavedAt] = useState<number | null>(null)
const [accountError, setAccountError] = useState<string | null>(null)
const loadAccount = useCallback(async () => {
if (!session) return
setAccountLoading(true)
setAccountError(null)
try {
const u = await getUser(arcadia, session.userId)
setAccount(u)
setAccountDraft({
first_name: u.first_name ?? "",
last_name: u.last_name ?? "",
email: u.email,
})
} catch (err) {
setAccountError(
err instanceof ArcadiaError ? err.message : "Failed to load account.",
)
} finally {
setAccountLoading(false)
}
}, [arcadia, session])
useEffect(() => {
setDraft(profile)
}, [profile])
loadAccount()
}, [loadAccount])
const dirty = JSON.stringify(draft) !== JSON.stringify(profile)
const initials = profileInitials(draft.name || DEFAULT_PROFILE.name)
const accountDirty =
!!account &&
(accountDraft.first_name !== (account.first_name ?? "") ||
accountDraft.last_name !== (account.last_name ?? "") ||
accountDraft.email !== account.email)
const saveAccount = async () => {
if (!account) return
setAccountSaving(true)
setAccountError(null)
try {
const updated = await updateUser(arcadia, account.id, {
first_name: accountDraft.first_name || null,
last_name: accountDraft.last_name || null,
email: accountDraft.email,
})
setAccount(updated)
updateSessionUser({ name: updated.full_name, email: updated.email })
setAccountSavedAt(Date.now())
} catch (err) {
setAccountError(
err instanceof ArcadiaError ? err.message : "Save failed.",
)
} finally {
setAccountSaving(false)
}
}
// Local prefs handlers.
const initials = profileInitials(
[accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") ||
account?.full_name ||
session?.name ||
"",
)
const onPickAvatar = (file: File | null) => {
if (!file) {
setDraft((d) => ({ ...d, avatarUrl: "" }))
setPrefs((d) => ({ ...d, avatarUrl: "" }))
return
}
const reader = new FileReader()
reader.onload = () => {
const result = reader.result
if (typeof result === "string")
setDraft((d) => ({ ...d, avatarUrl: result }))
setPrefs((d) => ({ ...d, avatarUrl: result }))
}
reader.readAsDataURL(file)
}
const save = () => {
saveProfile(draft)
setSavedAt(Date.now())
const savePrefs = () => {
saveProfile(prefs)
setPrefsSavedAt(Date.now())
}
const defaultAgent =
agents.find((a) => a.id === draft.defaultAgentId) ?? null
const defaultAgent = agents.find((a) => a.id === prefs.defaultAgentId) ?? null
return (
<AppShell title="Profile">
<AppShell>
<Card>
<CardHeader>
<CardTitle>You</CardTitle>
<CardTitle className="flex items-center gap-3">
Account
{account?.email_verified ? (
<Badge variant="default">Verified</Badge>
) : account ? (
<Badge variant="secondary">Unverified</Badge>
) : null}
{account?.status && account.status !== "active" ? (
<Badge variant="destructive">{account.status}</Badge>
) : null}
</CardTitle>
<CardDescription>
Personal info shown across the app appbar avatar, signatures, and
anywhere the assistant references you.
Your arcadia identity. Changes are saved to the platform and reflected
anywhere your name or email appears.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
{accountError ? (
<AlertBanner
variant="error"
dismissible
onDismiss={() => setAccountError(null)}
>
{accountError}
</AlertBanner>
) : null}
<div className="flex flex-wrap items-center gap-4">
<Avatar className="size-20 ring-2 ring-primary/30">
{draft.avatarUrl ? (
<AvatarImage src={draft.avatarUrl} alt={draft.name} />
{prefs.avatarUrl ? (
<AvatarImage src={prefs.avatarUrl} alt={accountDraft.email} />
) : null}
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
{initials}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1 text-sm">
<span className="font-medium">
{account?.full_name || accountDraft.email || "—"}
</span>
{account ? (
<>
<span className="text-xs text-muted-foreground">
Tenant <code className="font-mono">{account.tenant_id}</code> ·
ID <code className="font-mono">{account.id}</code>
</span>
<span className="text-xs text-muted-foreground">
Last sign-in{" "}
{account.last_sign_in_at
? new Date(account.last_sign_in_at).toLocaleString()
: "—"}
</span>
</>
) : null}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="First name">
<Input
data-action="profile-first-name"
value={accountDraft.first_name}
onChange={(e) =>
setAccountDraft((d) => ({ ...d, first_name: e.target.value }))
}
autoComplete="given-name"
disabled={accountLoading || accountSaving}
/>
</Field>
<Field label="Last name">
<Input
data-action="profile-last-name"
value={accountDraft.last_name}
onChange={(e) =>
setAccountDraft((d) => ({ ...d, last_name: e.target.value }))
}
autoComplete="family-name"
disabled={accountLoading || accountSaving}
/>
</Field>
<Field
label="Email"
hint="Updating your email may require re-verification."
>
<Input
data-action="profile-email"
type="email"
value={accountDraft.email}
onChange={(e) =>
setAccountDraft((d) => ({ ...d, email: e.target.value }))
}
autoComplete="email"
disabled={accountLoading || accountSaving}
/>
</Field>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="profile-account-save"
onClick={saveAccount}
disabled={!accountDirty || accountSaving || accountLoading}
>
{accountSaving ? (
<RefreshCw className="size-4 animate-spin" />
) : null}
Save account
</Button>
<Button
data-action="profile-account-revert"
variant="ghost"
onClick={() => {
if (!account) return
setAccountDraft({
first_name: account.first_name ?? "",
last_name: account.last_name ?? "",
email: account.email,
})
}}
disabled={!accountDirty || accountSaving}
>
Revert
</Button>
<Button
data-action="profile-account-refresh"
variant="ghost"
onClick={loadAccount}
disabled={accountLoading}
>
<RefreshCw
className={accountLoading ? "size-4 animate-spin" : "size-4"}
/>
Refresh
</Button>
{accountSavedAt && !accountDirty && (
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
<Check className="size-4" /> Saved.
</span>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>
Local-only settings stored in this browser avatar, bio, signature,
and the assistant's default persona.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Avatar</span>
<div className="flex items-center gap-3">
<label className="inline-flex w-fit cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground">
<input
data-action="profile-avatar-upload"
@@ -98,53 +320,32 @@ export default function ProfileRoute() {
/>
Upload avatar
</label>
{draft.avatarUrl && (
{prefs.avatarUrl && (
<Button
data-action="profile-avatar-remove"
variant="ghost"
size="sm"
onClick={() => onPickAvatar(null)}
className="w-fit text-muted-foreground"
className="text-muted-foreground"
>
<Trash2 className="size-3.5" /> Remove
</Button>
)}
<span className="text-xs text-muted-foreground">
PNG, JPG, or SVG. Stored locally as a data URL.
</span>
</div>
<span className="text-xs text-muted-foreground">
PNG, JPG, or SVG. Stored locally as a data URL.
</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Name">
<Input
data-action="profile-name"
value={draft.name}
onChange={(e) =>
setDraft((d) => ({ ...d, name: e.target.value }))
}
autoComplete="name"
/>
</Field>
<Field label="Email">
<Input
data-action="profile-email"
type="email"
value={draft.email}
onChange={(e) =>
setDraft((d) => ({ ...d, email: e.target.value }))
}
autoComplete="email"
/>
</Field>
<Field label="Title" hint="Your role at work.">
<Input
data-action="profile-title"
value={draft.title}
value={prefs.title}
onChange={(e) =>
setDraft((d) => ({ ...d, title: e.target.value }))
setPrefs((d) => ({ ...d, title: e.target.value }))
}
placeholder="e.g. Product designer"
placeholder="e.g. Platform admin"
/>
</Field>
<Field
@@ -173,9 +374,9 @@ export default function ProfileRoute() {
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
onClick={() =>
setDraft((d) => ({ ...d, defaultAgentId: "" }))
setPrefs((d) => ({ ...d, defaultAgentId: "" }))
}
data-state={!draft.defaultAgentId ? "checked" : undefined}
data-state={!prefs.defaultAgentId ? "checked" : undefined}
>
First available
</DropdownMenuItem>
@@ -183,10 +384,10 @@ export default function ProfileRoute() {
<DropdownMenuItem
key={a.id}
onClick={() =>
setDraft((d) => ({ ...d, defaultAgentId: a.id }))
setPrefs((d) => ({ ...d, defaultAgentId: a.id }))
}
data-state={
draft.defaultAgentId === a.id ? "checked" : undefined
prefs.defaultAgentId === a.id ? "checked" : undefined
}
className="flex flex-col items-start"
>
@@ -207,10 +408,8 @@ export default function ProfileRoute() {
>
<Textarea
data-action="profile-bio"
value={draft.bio}
onChange={(e) =>
setDraft((d) => ({ ...d, bio: e.target.value }))
}
value={prefs.bio}
onChange={(e) => setPrefs((d) => ({ ...d, bio: e.target.value }))}
rows={3}
placeholder="Tell the assistant about you."
/>
@@ -222,42 +421,42 @@ export default function ProfileRoute() {
>
<Textarea
data-action="profile-signature"
value={draft.signature}
value={prefs.signature}
onChange={(e) =>
setDraft((d) => ({ ...d, signature: e.target.value }))
setPrefs((d) => ({ ...d, signature: e.target.value }))
}
rows={3}
placeholder={`Cheers,\n${draft.name || "Your name"}`}
placeholder={`Cheers,\n${account?.full_name || "Your name"}`}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="profile-save"
onClick={save}
disabled={!dirty}
data-action="profile-prefs-save"
onClick={savePrefs}
disabled={!prefsDirty}
>
Save
Save preferences
</Button>
<Button
data-action="profile-revert"
data-action="profile-prefs-revert"
variant="ghost"
onClick={() => setDraft(profile)}
disabled={!dirty}
onClick={() => setPrefs(profile)}
disabled={!prefsDirty}
>
Revert
</Button>
<Button
data-action="profile-reset"
data-action="profile-prefs-reset"
variant="ghost"
onClick={() => {
resetProfile()
setSavedAt(Date.now())
setPrefsSavedAt(Date.now())
}}
>
Reset to defaults
</Button>
{savedAt && !dirty && (
{prefsSavedAt && !prefsDirty && (
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
<Check className="size-4" /> Saved.
</span>

View File

@@ -1,183 +0,0 @@
import { useEffect, useMemo, useState } from "react"
import { Plus, Search, Trash2 } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import {
createResource,
deleteResource,
seedResourcesIfEmpty,
updateResource,
useResources,
type Resource,
} from "~/lib/resources"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Resources")
const statuses: Resource["status"][] = ["active", "paused", "archived"]
export default function ResourcesRoute() {
const items = useResources()
const [query, setQuery] = useState("")
const [draftName, setDraftName] = useState("")
useEffect(() => {
seedResourcesIfEmpty()
}, [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return q
? items.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.owner.toLowerCase().includes(q) ||
r.status.includes(q),
)
: items
}, [items, query])
const create = () => {
const name = draftName.trim()
if (!name) return
createResource({ name, owner: "You" })
setDraftName("")
}
return (
<AppShell title="Resources">
<Card>
<CardHeader>
<CardTitle>Resources</CardTitle>
<CardDescription>
Example domain entity. CRUD goes through{" "}
<code className="font-mono text-xs">~/lib/resources.ts</code>
swap that file's calls for{" "}
<code className="font-mono text-xs">api.get/post/put/del</code>{" "}
from <code className="font-mono text-xs">~/lib/api.ts</code> when
you have a backend.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-48">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
data-action="resources-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search name, owner, status…"
className="pl-8"
/>
</div>
<Input
data-action="resources-new-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") create()
}}
placeholder="New resource name…"
className="max-w-64"
/>
<Button
data-action="resources-create"
onClick={create}
disabled={!draftName.trim()}
>
<Plus className="size-4" /> Add
</Button>
</div>
<div className="overflow-hidden rounded-lg border bg-card/40">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Name</th>
<th className="px-3 py-2 text-left font-medium">Owner</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-left font-medium">Updated</th>
<th className="w-10 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-3 py-8 text-center text-muted-foreground"
>
{items.length === 0
? "No resources yet — add one above."
: "No matches."}
</td>
</tr>
) : (
filtered.map((r) => (
<tr
key={r.id}
className="border-t transition-colors hover:bg-accent/30"
>
<td className="px-3 py-2 font-medium">{r.name}</td>
<td className="px-3 py-2 text-muted-foreground">
{r.owner}
</td>
<td className="px-3 py-2">
<select
data-action={`resources-status-${r.id}`}
value={r.status}
onChange={(e) =>
updateResource(r.id, {
status: e.target.value as Resource["status"],
})
}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
>
{statuses.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
{new Date(r.updatedAt).toLocaleDateString()}
</td>
<td className="px-2 py-2 text-right">
<Button
data-action={`resources-delete-${r.id}`}
variant="ghost"
size="icon-sm"
aria-label="Delete"
onClick={() => {
if (window.confirm(`Delete "${r.name}"?`))
deleteResource(r.id)
}}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground">
{items.length} total · {filtered.length} shown
</p>
</CardContent>
</Card>
</AppShell>
)
}

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CalendarClock,
CheckCircle2,
@@ -241,31 +240,9 @@ export default function ScheduledTasksRoute() {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Scheduled tasks">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>
Scheduled task administration requires an admin session.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/scheduled-tasks">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Scheduled tasks">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Scheduled tasks</h1>

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
AlertTriangle,
Clock,
@@ -248,31 +247,9 @@ export default function SecretsRoute() {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Secrets">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>
Secrets administration requires an admin session.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/secrets">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Secrets">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Secrets</h1>

View File

@@ -170,7 +170,7 @@ export default function SettingsRoute() {
}, [section])
return (
<AppShell title="Settings">
<AppShell>
<div className="grid gap-6 md:grid-cols-[14rem_1fr]">
<nav
aria-label="Settings sections"

44
app/routes/signup.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { useEffect } from "react"
import { useNavigate } from "react-router"
import { SignupForm } from "@crema/arcadia-auth-ui"
import { useBrand } from "~/lib/identity"
import { pageTitle } from "~/lib/page-meta"
import { persistFromArcadiaLogin, useSession } from "~/lib/session"
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
export const meta = () => pageTitle("Create account")
export default function SignupRoute() {
const navigate = useNavigate()
const session = useSession()
const brand = useBrand()
useEffect(() => {
if (session) navigate("/", { replace: true })
}, [session, navigate])
return (
<AuthShell>
<SignupForm
brand={<AuthBrand />}
heading={`Join ${brand.name}`}
onSignin={() => navigate("/login")}
onSuccess={async ({ tokens, user, emailVerificationSent }) => {
if (tokens) {
persistFromArcadiaLogin(tokens, user)
navigate("/", { replace: true })
return
}
// No tokens returned — verification email gating. Bounce to login.
navigate(
emailVerificationSent
? "/login?verify=sent"
: "/login",
{ replace: true },
)
}}
/>
</AuthShell>
)
}

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
KeyRound,
@@ -209,29 +208,9 @@ export default function SsoRoute() {
initialPageSize: 25,
})
if (!session) {
return (
<AppShell title="SSO">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>SSO administration requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/sso">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="SSO">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Single sign-on</h1>

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
AlertTriangle,
CheckCircle2,
@@ -172,29 +171,9 @@ export default function StatusPageRoute() {
[uiComponents],
)
if (!session) {
return (
<AppShell title="Status page">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Status page admin requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/status-page">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Status page">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Status page</h1>

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
HardDrive,
@@ -264,31 +263,9 @@ export default function StorageRoute() {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Storage">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>
Storage administration requires an admin session.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/storage">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Storage">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Storage</h1>

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import { Pause, Play, Plus, RefreshCw } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
@@ -18,6 +17,7 @@ import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { Button } from "~/components/ui/button"
import {
Card,
@@ -174,39 +174,13 @@ export default function TenantsRoute() {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Tenants">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>
Tenant administration requires an admin session.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/tenants">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Tenants">
<div className="flex flex-col gap-4 p-6">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Tenants</h1>
<p className="text-sm text-muted-foreground">
Multi-tenant workspaces on this arcadia deployment.
</p>
</div>
<div className="flex items-center gap-2">
<AppShell>
<PageHeader
title="Tenants"
description="Multi-tenant workspaces on this arcadia deployment."
actions={
<>
<Button
variant="outline"
size="sm"
@@ -221,8 +195,9 @@ export default function TenantsRoute() {
<Plus className="size-4" />
New tenant
</Button>
</div>
</header>
</>
}
/>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
@@ -276,7 +251,6 @@ export default function TenantsRoute() {
)}
</CardContent>
</Card>
</div>
<ConfirmDialog
open={pending?.kind === "suspend"}

View File

@@ -169,29 +169,9 @@ export default function UsersRoute() {
)
useRegisterAdminContext("users", summary)
if (!session) {
return (
<AppShell title="Users">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>User administration requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/users">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Users">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Users</h1>
<p className="text-sm text-muted-foreground">

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
Clock,
@@ -242,29 +241,9 @@ export default function WebhooksRoute() {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Webhooks">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Webhook administration requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/webhooks">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Webhooks">
<div className="flex flex-col gap-4 p-6">
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Webhooks</h1>