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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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
61
app/routes/login.2fa.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
app/routes/login.forgot.tsx
Normal file
50
app/routes/login.forgot.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
app/routes/login.reset.tsx
Normal file
47
app/routes/login.reset.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & health</h1>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
44
app/routes/signup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user