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

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
.DS_Store
.env
.env.local
.env.*.local
/node_modules/
# React Router

View File

@@ -24,6 +24,8 @@ To use it for real:
|---|---|---|
| `VITE_ARCADIA_URL` | `http://localhost:4000` | Base URL of arcadia-core. |
| `VITE_ARCADIA_TENANT` | `default` | Tenant id sent as `X-Tenant-ID`. Override per-deployment. |
| `VITE_ARCADIA_SEARCH_URL` | `http://127.0.0.1:7800` | Base URL of arcadia-search (Tantivy). |
| `VITE_ARCADIA_SEARCH_TOKEN` | _(unset)_ | Service-principal JWT for the assistant's `search_kb`/`read_chunk` tools. Set this when arcadia-search runs in `AUTH_MODE=jwt` and doesn't share its signing secret with the arcadia issuing operator session tokens. When unset, the operator's own session JWT is used (works only with matched signing keys). |
## What's in here

View File

@@ -0,0 +1,33 @@
import { type ReactNode } from "react"
import { useBrand } from "~/lib/identity"
export function AuthShell({ children }: { children: ReactNode }) {
return (
<div
className="dark relative isolate flex min-h-svh items-center justify-center p-4"
style={{ background: "var(--background)" }}
>
{children}
</div>
)
}
export function AuthBrand() {
const brand = useBrand()
const BrandIcon = brand.icon
return (
<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>
)
}

View File

@@ -118,7 +118,6 @@ const navItems: NavItem[] = [
]
type AppShellProps = {
title: string
children: React.ReactNode
brand?: Brand
user?: User
@@ -131,7 +130,6 @@ type AppShellProps = {
}
export function AppShell({
title,
children,
brand: brandOverride,
user: userOverride,
@@ -143,14 +141,11 @@ export function AppShell({
const session = useSession()
const navigate = useNavigate()
const brand = brandOverride ?? defaultBrand
// Prefer the live session for identity, fall back to the editable profile,
// fall back to the stub user.
// Prefer the live session for identity, fall back to the stub user.
const user = userOverride ?? {
name: session?.name || profile.name || defaultUser.name,
email: session?.email || profile.email || defaultUser.email,
initials: profileInitials(
session?.name || profile.name || defaultUser.name,
),
name: session?.name || defaultUser.name,
email: session?.email || defaultUser.email,
initials: profileInitials(session?.name || defaultUser.name),
}
// Protected shell: bounce to /login when there's no session.
@@ -163,7 +158,9 @@ export function AppShell({
navigate(`/login?next=${next}`, { replace: true })
}
}, [session, navigate])
if (!session) return null
// All hooks must run unconditionally — keep them above the session
// short-circuit so a sign-out doesn't reduce the hook count and trip
// React's "rendered fewer hooks than expected" check.
const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1"
@@ -173,10 +170,11 @@ export function AppShell({
}, [expanded])
const [mobileOpen, setMobileOpen] = useState(false)
const [scriptsOpen, setScriptsOpen] = useState(false)
const BrandIcon = brand.icon
useScriptsHotkey(() => setScriptsOpen(true))
if (!session) return null
const BrandIcon = brand.icon
return (
<div
data-theme={theme}
@@ -389,7 +387,10 @@ export function AppShell({
<div
id="main-content"
tabIndex={-1}
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none"
// First-child padding clears the fixed top-right floating actions
// pill so page headers can put refresh/new buttons in their normal
// top-right slot without sliding under the appbar avatar/controls.
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none [&>*:first-child]:lg:pr-72"
>
{children}
</div>

View File

@@ -0,0 +1,36 @@
import { type ReactNode } from "react"
interface PageHeaderProps {
title: ReactNode
description?: ReactNode
/** Inline indicators after the title (badges, status pills). */
badges?: ReactNode
/** Toolbar rendered below the title row — primary actions go here. */
actions?: ReactNode
}
// Right-side space for the appbar's floating actions pill is reserved by
// the AppShell's first-child padding rule, not here — keep this layout
// concerned only with title/description/actions composition.
export function PageHeader({
title,
description,
badges,
actions,
}: PageHeaderProps) {
return (
<header className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{badges}
</div>
{description ? (
<p className="max-w-3xl text-sm text-muted-foreground">{description}</p>
) : null}
{actions ? (
<div className="mt-1 flex flex-wrap items-center gap-2">{actions}</div>
) : null}
</header>
)
}

View File

@@ -118,7 +118,7 @@ export function ScriptsDialog({
data-action="scripts-dsl"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={"navigate /resources\nclick nav-resources"}
placeholder={"navigate /tenants\nclick nav-tenants"}
spellCheck={false}
rows={6}
className="w-full rounded-md border bg-background p-2 font-mono text-xs"

View File

@@ -10,11 +10,24 @@ import type { Tool, ToolCall as LLMToolCall } from "@crema/llm-ui"
import {
activateTenant,
deactivateTenant,
getTenant,
listTenants,
suspendTenant,
type Tenant,
} from "~/lib/arcadia/tenants"
import {
assignRole,
createUser,
deleteUser,
removeRole,
setUserStatus,
updateUser,
type UserStatus,
} from "~/lib/arcadia/users"
import { listMemberships } from "~/lib/arcadia/memberships"
import { listRoles } from "~/lib/arcadia/roles"
import { revokeUserApiKey } from "~/lib/arcadia/api-keys"
import { createRAGClient } from "@crema/lexical-rag-ui"
import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas"
@@ -27,27 +40,88 @@ const docsClient = createRAGClient("/docs-index.json")
// URL: comes from window.__ARCADIA_SEARCH_URL (override hook) or
// VITE_ARCADIA_SEARCH_URL build-time env, defaulting to localhost.
//
// Token: prefer the real arcadia access token (sessionStorage —
// matches lib-arcadia-client's storage convention). Fall back to "dev"
// when missing, which only works against AUTH_MODE=dev backends. In
// production, arcadia-search runs in JWT mode and the dev fallback
// gets rejected with 401 — surfacing the missing-login as a clear
// error rather than silently using the wrong identity.
// Token (resolution order):
// 1. window.__ARCADIA_SEARCH_TOKEN — runtime override hook for tests/devtools.
// 2. VITE_ARCADIA_SEARCH_TOKEN — build-time service-principal token.
// Required when arcadia-search runs in AUTH_MODE=jwt and arcadia-admin
// talks to a remote arcadia whose JWT signing secret arcadia-search
// doesn't share. Issue this once from arcadia-admin's service-principal
// tooling and wire it through `.env.local`.
// 3. operator session JWT — works only when arcadia-search shares the
// JWT signing secret with the arcadia issuing the operator's session
// (i.e. local arcadia-app + local arcadia-search with matching keys).
// 4. "dev" literal — only accepted by AUTH_MODE=dev backends.
function readEnv(key: string): string | undefined {
if (typeof import.meta === "undefined") return undefined
return (import.meta as unknown as { env?: Record<string, string | undefined> })
.env?.[key]
}
const KB_BASE_URL: string =
(typeof window !== "undefined" &&
(window as unknown as { __ARCADIA_SEARCH_URL?: string }).__ARCADIA_SEARCH_URL) ||
(typeof import.meta !== "undefined" &&
(import.meta as unknown as { env?: { VITE_ARCADIA_SEARCH_URL?: string } }).env
?.VITE_ARCADIA_SEARCH_URL) ||
readEnv("VITE_ARCADIA_SEARCH_URL") ||
"http://127.0.0.1:7800"
function kbAuthToken(): string {
if (typeof window === "undefined") return "dev"
try {
return window.sessionStorage.getItem("arcadia_access_token") ?? "dev"
} catch {
return "dev"
const KB_SERVICE_TOKEN: string | undefined = readEnv("VITE_ARCADIA_SEARCH_TOKEN")
type TokenSource = "override" | "service" | "session" | "dev"
function kbAuthToken(): { token: string; source: TokenSource } {
if (typeof window !== "undefined") {
const override = (window as unknown as { __ARCADIA_SEARCH_TOKEN?: string })
.__ARCADIA_SEARCH_TOKEN
if (override) return { token: override, source: "override" }
}
if (KB_SERVICE_TOKEN) return { token: KB_SERVICE_TOKEN, source: "service" }
if (typeof window === "undefined") return { token: "dev", source: "dev" }
try {
const stored = window.sessionStorage.getItem("arcadia_access_token")
if (stored) return { token: stored, source: "session" }
} catch {
// fall through
}
return { token: "dev", source: "dev" }
}
// True when the operator's session JWT was minted by an arcadia other than
// the one hosting search — i.e. signing keys almost certainly don't match
// and a session-token fallback will 401 silently. We treat any non-localhost
// arcadia URL as "remote" for this heuristic.
function isRemoteArcadia(): boolean {
const url = readEnv("VITE_ARCADIA_URL") ?? ""
if (!url) return false
return !/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(url)
}
function kbAuthHint(source: TokenSource): string {
if (source === "service" || source === "override") {
return "VITE_ARCADIA_SEARCH_TOKEN was rejected — verify it's signed with the secret arcadia-search expects (JWT_HMAC_SECRET) and hasn't expired."
}
if (source === "session" && isRemoteArcadia()) {
return "Set VITE_ARCADIA_SEARCH_TOKEN in arcadia-admin/.env.local to a service-principal JWT signed with arcadia-search's JWT_HMAC_SECRET. The operator session JWT (from a remote arcadia) won't validate against a locally-keyed arcadia-search."
}
if (source === "session") {
return "Operator session JWT was rejected — arcadia-search's JWT_HMAC_SECRET likely doesn't match the arcadia that issued the session. Either align secrets or set VITE_ARCADIA_SEARCH_TOKEN."
}
return "arcadia-search rejected the dev fallback — it's running in AUTH_MODE=jwt. Set VITE_ARCADIA_SEARCH_TOKEN or restart arcadia-search with AUTH_MODE=dev for local testing."
}
async function kbFetch(input: string, init?: RequestInit): Promise<Response> {
const { token, source } = kbAuthToken()
const res = await fetch(input, {
...init,
headers: {
...(init?.headers ?? {}),
Authorization: `Bearer ${token}`,
},
})
if (res.status === 401 || res.status === 403) {
throw new Error(
`arcadia-search rejected the request (${res.status}). ${kbAuthHint(source)}`,
)
}
return res
}
type KBHit = {
@@ -67,12 +141,9 @@ async function kbSearch(
limit: number,
tags?: string[],
): Promise<{ count: number; hits: KBHit[] }> {
const res = await fetch(`${KB_BASE_URL}/search`, {
const res = await kbFetch(`${KB_BASE_URL}/search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${kbAuthToken()}`,
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, corpus, limit, tags }),
})
if (!res.ok) {
@@ -83,9 +154,7 @@ async function kbSearch(
async function kbRead(chunkId: string, corpus: string): Promise<unknown> {
const url = `${KB_BASE_URL}/chunks/${encodeURIComponent(chunkId)}?corpus=${encodeURIComponent(corpus)}`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${kbAuthToken()}` },
})
const res = await kbFetch(url)
if (res.status === 404) return null
if (!res.ok) {
throw new Error(`arcadia-search ${res.status}: ${await res.text()}`)
@@ -298,6 +367,320 @@ const TOOLS: ToolDef[] = [
return summarize(updated)
},
},
{
name: "deactivate_tenant",
description:
"Permanently deactivate a tenant by slug. Stronger than suspend — also revokes API keys and disables billing. Reversible only via activate_tenant. Use when a tenant is closing the account, not for short-term holds. Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
slug: { type: "string", description: "The tenant's slug." },
},
required: ["slug"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const slug = typeof args.slug === "string" ? args.slug : null
if (!slug) throw new Error("deactivate_tenant requires { slug }")
const tenants = await listTenants(arcadia)
const target = tenants.find((t) => t.slug === slug)
if (!target) throw new Error(`No tenant with slug "${slug}"`)
const updated = await deactivateTenant(arcadia, target.id)
return summarize(updated)
},
},
{
name: "set_user_status",
description:
"Change a user's status to active, inactive, or suspended. Suspended users cannot sign in; inactive users are hidden from default lists but retain their data. Pass the user's id (UUID). Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
user_id: { type: "string", description: "User UUID." },
status: {
type: "string",
enum: ["active", "inactive", "suspended"],
description: "Target status.",
},
},
required: ["user_id", "status"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const userId = typeof args.user_id === "string" ? args.user_id : null
const status = typeof args.status === "string" ? (args.status as UserStatus) : null
if (!userId || !status)
throw new Error("set_user_status requires { user_id, status }")
const updated = await setUserStatus(arcadia, userId, status)
return {
id: updated.id,
email: updated.email,
status: updated.status,
full_name: updated.full_name,
}
},
},
{
name: "delete_user",
description:
"Permanently delete a user by id. Cascades to their memberships and API keys. NOT reversible — prefer set_user_status with 'inactive' or 'suspended' unless the user explicitly asks for permanent deletion. Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
user_id: { type: "string", description: "User UUID." },
},
required: ["user_id"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const userId = typeof args.user_id === "string" ? args.user_id : null
if (!userId) throw new Error("delete_user requires { user_id }")
await deleteUser(arcadia, userId)
return { id: userId, deleted: true }
},
},
{
name: "list_memberships",
description:
"List user-to-tenant memberships. Returns user/tenant pairs with role assignments and primary-membership flag. Filter by tenant_slug to answer 'who's in tenant X', or by user_id to answer 'which tenants does user Y belong to'.",
parameters: {
type: "object",
properties: {
tenant_slug: {
type: "string",
description: "Optional: filter to a single tenant by slug.",
},
user_id: {
type: "string",
description: "Optional: filter to a single user by UUID.",
},
},
additionalProperties: false,
},
isWrite: false,
run: async (args, { arcadia }) => {
const slug = typeof args.tenant_slug === "string" ? args.tenant_slug : null
const userId = typeof args.user_id === "string" ? args.user_id : null
const all = await listMemberships(arcadia)
const filtered = all.filter((m) => {
if (slug && m.tenant?.slug !== slug) return false
if (userId && m.user?.id !== userId) return false
return true
})
return filtered.map((m) => ({
id: m.id,
tenant: m.tenant ? { slug: m.tenant.slug, name: m.tenant.name } : null,
user: m.user
? {
id: m.user.id,
email: m.user.email,
name:
[m.user.first_name, m.user.last_name].filter(Boolean).join(" ") ||
null,
}
: null,
status: m.status,
is_primary: m.is_primary,
roles: m.roles.map((r) => r.slug),
joined_at: m.joined_at,
}))
},
},
{
name: "list_roles",
description:
"List every role defined in the current tenant. Returns slug, name, description, permission set, and is_system flag. Use to answer 'what roles are available' or before assigning a role.",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
isWrite: false,
run: async (_args, { arcadia }) => {
const roles = await listRoles(arcadia)
return roles.map((r) => ({
id: r.id,
slug: r.slug,
name: r.name,
description: r.description,
permissions: r.permissions,
is_system: r.is_system,
}))
},
},
{
name: "create_user",
description:
"Create a new user in the current tenant. Pass email (required) plus optional first_name, last_name, status, password, and role_ids. If password is omitted the user must set one via the password-reset flow. Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
email: { type: "string", description: "User email address." },
first_name: { type: "string" },
last_name: { type: "string" },
status: {
type: "string",
enum: ["active", "inactive", "suspended"],
},
password: {
type: "string",
description:
"Optional initial password. Omit to require the user to use the password-reset flow.",
},
role_ids: {
type: "array",
items: { type: "string" },
description: "Optional UUIDs of roles to assign on creation.",
},
},
required: ["email"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const email = typeof args.email === "string" ? args.email : null
if (!email) throw new Error("create_user requires { email }")
const created = await createUser(arcadia, {
email,
first_name: typeof args.first_name === "string" ? args.first_name : undefined,
last_name: typeof args.last_name === "string" ? args.last_name : undefined,
status:
typeof args.status === "string"
? (args.status as UserStatus)
: undefined,
password: typeof args.password === "string" ? args.password : undefined,
role_ids: Array.isArray(args.role_ids)
? (args.role_ids.filter((r) => typeof r === "string") as string[])
: undefined,
})
return {
id: created.id,
email: created.email,
full_name: created.full_name,
status: created.status,
roles: created.roles.map((r) => r.slug),
}
},
},
{
name: "update_user",
description:
"Update a user's name or email by id. For status changes use set_user_status; for role assignment use assign_role/remove_role. Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
user_id: { type: "string", description: "User UUID." },
email: { type: "string" },
first_name: { type: "string" },
last_name: { type: "string" },
},
required: ["user_id"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const userId = typeof args.user_id === "string" ? args.user_id : null
if (!userId) throw new Error("update_user requires { user_id }")
const patch: Record<string, string> = {}
if (typeof args.email === "string") patch.email = args.email
if (typeof args.first_name === "string") patch.first_name = args.first_name
if (typeof args.last_name === "string") patch.last_name = args.last_name
if (Object.keys(patch).length === 0)
throw new Error("update_user needs at least one field to change")
const updated = await updateUser(arcadia, userId, patch)
return {
id: updated.id,
email: updated.email,
full_name: updated.full_name,
status: updated.status,
}
},
},
{
name: "assign_role",
description:
"Grant a role to a user by user_id and role_id. Idempotent — re-granting an existing role is a no-op. Use list_roles first to find the role's id. Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
user_id: { type: "string", description: "User UUID." },
role_id: { type: "string", description: "Role UUID." },
},
required: ["user_id", "role_id"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const userId = typeof args.user_id === "string" ? args.user_id : null
const roleId = typeof args.role_id === "string" ? args.role_id : null
if (!userId || !roleId)
throw new Error("assign_role requires { user_id, role_id }")
const updated = await assignRole(arcadia, userId, roleId)
return {
id: updated.id,
email: updated.email,
roles: updated.roles.map((r) => r.slug),
}
},
},
{
name: "remove_role",
description:
"Revoke a role from a user by user_id and role_id. Idempotent — removing a role the user doesn't have is a no-op. Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
user_id: { type: "string", description: "User UUID." },
role_id: { type: "string", description: "Role UUID." },
},
required: ["user_id", "role_id"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const userId = typeof args.user_id === "string" ? args.user_id : null
const roleId = typeof args.role_id === "string" ? args.role_id : null
if (!userId || !roleId)
throw new Error("remove_role requires { user_id, role_id }")
const updated = await removeRole(arcadia, userId, roleId)
return {
id: updated.id,
email: updated.email,
roles: updated.roles.map((r) => r.slug),
}
},
},
{
name: "revoke_api_key",
description:
"Revoke a user's API key by id. The key stops working immediately and cannot be un-revoked — the user must mint a new one. Use for compromised keys or offboarding. Requires user confirmation before executing.",
parameters: {
type: "object",
properties: {
user_id: { type: "string", description: "Owner user UUID." },
key_id: { type: "string", description: "API key UUID." },
reason: {
type: "string",
description: "Optional audit-log reason for the revocation.",
},
},
required: ["user_id", "key_id"],
additionalProperties: false,
},
isWrite: true,
run: async (args, { arcadia }) => {
const userId = typeof args.user_id === "string" ? args.user_id : null
const keyId = typeof args.key_id === "string" ? args.key_id : null
const reason = typeof args.reason === "string" ? args.reason : undefined
if (!userId || !keyId)
throw new Error("revoke_api_key requires { user_id, key_id }")
await revokeUserApiKey(arcadia, userId, keyId, reason)
return { user_id: userId, key_id: keyId, revoked: true }
},
},
{
name: "search_docs",
description:

View File

@@ -21,28 +21,28 @@ export const DEFAULT_AGENTS: Agent[] = [
},
{
id: "auditor",
name: "Ledger",
name: "Notary",
role: "Auditor",
prompt:
"You're an audit-focused assistant inside Arcadia Admin. Specialise in audit logs, access reviews, and 'who did what when' questions. Always cite the actor_type (user / platform_admin / api_key / system) and timestamp when summarising audit entries. Be cautious about claims you can't back with a tool result — call a tool first.",
},
{
id: "triage",
name: "Beacon",
name: "Tracer",
role: "Incident Triage",
prompt:
"You're an incident-triage assistant inside Arcadia Admin. When the user reports a problem (a tenant member can't sign in, a billing call is 402'ing, a webhook is failing), walk the diagnostic tree: identify the tenant, check tenant status, check the user's roles, check the billing-config / api-metering / feature-flag overrides as relevant. Suggest impersonation only when it's the right escalation. Keep a clear hypothesis → check → result rhythm.",
},
{
id: "analyst",
name: "Tally",
name: "Census",
role: "Platform Analyst",
prompt:
"You're an analyst inside Arcadia Admin. Answer numerical and aggregate questions across the platform: tenant counts by status, plan distribution, audit-log volume, growth. Always pull live data via tools — never guess from stale snapshots. Present findings in plain prose first, then a small table when the breakdown helps.",
},
{
id: "ui-driver",
name: "Cursor",
name: "Pilot",
role: "UI Operator",
prompt:
"You specialise in driving Arcadia Admin's UI on the operator's behalf. Prefer doing over explaining. When the user asks for an action that maps to a UI element, emit an action block immediately (using `data-action` ids the host has documented). For data questions, prefer tool calls over UI navigation.",
@@ -68,8 +68,16 @@ function isAgent(v: unknown): v is Agent {
// generic defaults from before Arcadia Admin had its own personas.
const LEGACY_AGENT_IDS = new Set(["generalist", "coder", "writer", "researcher"])
// Retired arcadia-era persona names. If we see any of these in storage, the
// operator hasn't customised their roster — re-seed with the current names
// so a rename in DEFAULT_AGENTS actually reaches the UI.
const RETIRED_AGENT_NAMES = new Set(["Ledger", "Beacon", "Tally", "Cursor"])
function isLegacyDefaultSet(agents: Agent[]): boolean {
return agents.some((a) => LEGACY_AGENT_IDS.has(a.id))
return (
agents.some((a) => LEGACY_AGENT_IDS.has(a.id)) ||
agents.some((a) => RETIRED_AGENT_NAMES.has(a.name))
)
}
function readFromStorage(): Agent[] {

View File

@@ -1,11 +1,10 @@
// User profile — name, email, title, bio, signature, default agent.
// Persisted in localStorage; reactive across tabs.
// Local user preferences — title, bio, signature, avatar, default agent.
// Persisted in localStorage; reactive across tabs. Identity (name, email)
// is owned by the arcadia session, not this store — see ~/lib/session.ts.
import { useEffect, useSyncExternalStore } from "react"
export type Profile = {
name: string
email: string
title: string
bio: string
signature: string
@@ -14,8 +13,6 @@ export type Profile = {
}
export const DEFAULT_PROFILE: Profile = {
name: "Signed-in user",
email: "user@example.com",
title: "",
bio: "",
signature: "",
@@ -33,12 +30,6 @@ function readFromStorage(): Profile {
if (!raw) return DEFAULT_PROFILE
const parsed = JSON.parse(raw) as Partial<Profile>
return {
name:
typeof parsed.name === "string" && parsed.name.trim().length > 0
? parsed.name
: DEFAULT_PROFILE.name,
email:
typeof parsed.email === "string" ? parsed.email : DEFAULT_PROFILE.email,
title:
typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title,
bio: typeof parsed.bio === "string" ? parsed.bio : DEFAULT_PROFILE.bio,

View File

@@ -1,32 +0,0 @@
import { describe, expect, it, beforeEach } from "vitest"
import {
createResource,
deleteResource,
listResources,
updateResource,
} from "./resources"
describe("resources", () => {
beforeEach(() => {
localStorage.clear()
})
it("creates, updates, and deletes", () => {
expect(listResources()).toEqual([])
const r = createResource({ name: "Test", owner: "Atlas" })
expect(r.status).toBe("active")
expect(listResources()).toHaveLength(1)
const updated = updateResource(r.id, { status: "paused" })
expect(updated?.status).toBe("paused")
expect(updated?.updatedAt).toBeGreaterThanOrEqual(r.updatedAt)
deleteResource(r.id)
expect(listResources()).toEqual([])
})
it("ignores updates for unknown ids", () => {
expect(updateResource("missing", { name: "x" })).toBeNull()
})
})

View File

@@ -1,157 +0,0 @@
// Resource store — example domain entity.
// Backed by localStorage today, but written so each call is a single function
// you can swap with `api.get/post/put/del` once you have a real backend.
import { useEffect, useSyncExternalStore } from "react"
export type Resource = {
id: string
name: string
status: "active" | "paused" | "archived"
owner: string
createdAt: number
updatedAt: number
}
const STORAGE_KEY = "crema.resources"
const CHANGE_EVENT = "crema:resources-change"
function newId() {
return `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
function readFromStorage(): Resource[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(
(r): r is Resource =>
r &&
typeof r.id === "string" &&
typeof r.name === "string" &&
["active", "paused", "archived"].includes(r.status) &&
typeof r.owner === "string" &&
typeof r.createdAt === "number" &&
typeof r.updatedAt === "number",
)
} catch {
return []
}
}
function write(items: Resource[]) {
if (typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
/* quota */
}
}
// CRUD — these mirror what `api.get/post/put/del` would look like.
export function listResources(): Resource[] {
return readFromStorage()
}
export function createResource(input: {
name: string
owner: string
status?: Resource["status"]
}): Resource {
const now = Date.now()
const r: Resource = {
id: newId(),
name: input.name,
owner: input.owner,
status: input.status ?? "active",
createdAt: now,
updatedAt: now,
}
write([r, ...readFromStorage()])
return r
}
export function updateResource(
id: string,
patch: Partial<Omit<Resource, "id" | "createdAt">>,
): Resource | null {
const items = readFromStorage()
let updated: Resource | null = null
const next = items.map((r) => {
if (r.id !== id) return r
updated = { ...r, ...patch, updatedAt: Date.now() }
return updated
})
if (updated) write(next)
return updated
}
export function deleteResource(id: string) {
write(readFromStorage().filter((r) => r.id !== id))
}
let cached: Resource[] | null = null
function subscribe(cb: () => void) {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): Resource[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Resource[] {
return []
}
export function useResources(): Resource[] {
const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return v
}
/** Seed a few rows on first load so the table isn't empty. */
export function seedResourcesIfEmpty() {
if (typeof window === "undefined") return
if (localStorage.getItem(STORAGE_KEY)) return
const now = Date.now()
const seed: Resource[] = [
{
id: newId(),
name: "Acme dashboard",
status: "active",
owner: "Atlas",
createdAt: now - 86_400_000 * 3,
updatedAt: now - 3600_000,
},
{
id: newId(),
name: "Onboarding pipeline",
status: "paused",
owner: "Forge",
createdAt: now - 86_400_000 * 7,
updatedAt: now - 86_400_000,
},
{
id: newId(),
name: "Q1 report draft",
status: "archived",
owner: "Inkwell",
createdAt: now - 86_400_000 * 30,
updatedAt: now - 86_400_000 * 14,
},
]
write(seed)
}

View File

@@ -1,10 +1,17 @@
import { describe, expect, it, beforeEach } from "vitest"
import { hasSession, loadSession, signIn, signOut } from "./session"
import {
hasSession,
loadSession,
persistFromArcadiaLogin,
signOut,
updateSessionUser,
} from "./session"
describe("session", () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
it("starts unauthenticated", () => {
@@ -12,20 +19,31 @@ describe("session", () => {
expect(hasSession()).toBe(false)
})
it("rejects empty credentials", async () => {
await expect(signIn("", "")).rejects.toThrow(/required/i)
await expect(signIn("not-an-email", "pw")).rejects.toThrow(/valid email/i)
expect(hasSession()).toBe(false)
})
it("creates a session on sign-in and clears on sign-out", async () => {
const session = await signIn("alice@example.com", "hunter2")
it("persists from an arcadia login and clears on sign-out", () => {
const session = persistFromArcadiaLogin(
{ access_token: "tok-123", refresh_token: "ref-456" },
{ id: "u1", email: "alice@example.com", full_name: "Alice" },
)
expect(session.email).toBe("alice@example.com")
expect(session.token).toMatch(/^dev-/)
expect(session.name).toBe("Alice")
expect(session.token).toBe("tok-123")
expect(hasSession()).toBe(true)
expect(sessionStorage.getItem("arcadia_access_token")).toBe("tok-123")
signOut()
expect(loadSession()).toBeNull()
expect(hasSession()).toBe(false)
expect(sessionStorage.getItem("arcadia_access_token")).toBeNull()
})
it("updates the stored session identity in place", () => {
persistFromArcadiaLogin(
{ access_token: "tok" },
{ id: "u1", email: "a@x.com", full_name: "Alice" },
)
updateSessionUser({ name: "Alice Smith", email: "alice@x.com" })
const s = loadSession()
expect(s?.name).toBe("Alice Smith")
expect(s?.email).toBe("alice@x.com")
expect(s?.token).toBe("tok")
})
})

View File

@@ -1,6 +1,7 @@
// Session — minimal auth scaffold backed by localStorage.
// Swap loadSession/signIn/signOut for real calls (cookies + server) when you
// wire a backend. The shape here matches what AppShell + useUser expect.
// Sign-in is owned by `persistFromArcadiaLogin`, which is called by the auth
// routes after a successful arcadia API exchange. The shape here matches what
// AppShell + useUser expect.
import { useEffect, useSyncExternalStore } from "react"
@@ -50,35 +51,6 @@ export function loadSession(): Session | null {
return readFromStorage()
}
/**
* Mock sign-in. Validates only that email + password are non-empty; returns
* a fake session. Replace with a real fetch to your auth endpoint.
*/
export async function signIn(
email: string,
password: string,
): Promise<Session> {
await new Promise((r) => setTimeout(r, 250))
if (!email.trim() || !password.trim()) {
throw new Error("Email and password are required.")
}
if (!email.includes("@")) {
throw new Error("Enter a valid email address.")
}
const session: Session = {
userId: `u-${Date.now().toString(36)}`,
name: email.split("@")[0].replace(/\W/g, " ").trim() || email,
email,
token: `dev-${Math.random().toString(36).slice(2, 14)}`,
issuedAt: Date.now(),
}
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
return session
}
export function signOut() {
if (typeof window === "undefined") return
localStorage.removeItem(STORAGE_KEY)
@@ -116,6 +88,26 @@ export function persistFromArcadiaLogin(
return session
}
/** Patch the stored session's identity fields without changing the token.
* Use after the operator edits their profile so the appbar avatar and
* protected-shell greeting reflect the new name/email immediately. */
export function updateSessionUser(patch: {
name?: string
email?: string
}): Session | null {
if (typeof window === "undefined") return null
const current = readFromStorage()
if (!current) return null
const next: Session = {
...current,
name: patch.name?.trim() ? patch.name : current.name,
email: patch.email?.trim() ? patch.email : current.email,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
return next
}
/** True if a non-expired session is in storage. */
export function hasSession(): boolean {
return !!readFromStorage()

View File

@@ -10,7 +10,7 @@ import {
import type { Route } from "./+types/root"
import "./app.css"
import { ToastProvider } from "@crema/notification-ui"
import { ToastProvider, Toaster } from "@crema/notification-ui"
import { CommandBusProvider } from "@crema/action-bus"
import { ArcadiaProvider } from "@crema/arcadia-client"
// CREMA:PROVIDERS-IMPORTS
@@ -62,6 +62,7 @@ export default function App() {
>
<CommandBusProvider>
<Outlet />
<Toaster />
</CommandBusProvider>
</ArcadiaProvider>
</ToastProvider>

View File

@@ -2,7 +2,6 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"
export default [
index("routes/home.tsx"),
route("resources", "routes/resources.tsx"),
route("activity", "routes/activity.tsx"),
route("assistant", "routes/assistant.tsx"),
route("ai", "routes/ai.tsx"),
@@ -10,6 +9,10 @@ export default [
route("settings", "routes/settings.tsx"),
route("profile", "routes/profile.tsx"),
route("login", "routes/login.tsx"),
route("login/forgot", "routes/login.forgot.tsx"),
route("login/reset", "routes/login.reset.tsx"),
route("login/2fa", "routes/login.2fa.tsx"),
route("signup", "routes/signup.tsx"),
route("tenants", "routes/tenants.tsx"),
route("storage", "routes/storage.tsx"),
route("users", "routes/users.tsx"),

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>

View File

@@ -11,7 +11,8 @@
"test": "vitest run",
"test:watch": "vitest",
"sync-libs": "node scripts/sync-libs.mjs",
"build:docs": "node scripts/build-docs-index.mjs"
"build:docs": "node scripts/build-docs-index.mjs",
"mint:search-token": "node scripts/mint-search-token.mjs"
},
"dependencies": {
"@base-ui/react": "^1.4.0",

View File

@@ -22,6 +22,7 @@ const ARCADIA = resolve(ROOT, "../reference/arcadia-app")
const OUT = resolve(ROOT, "public/docs-index.json")
const SOURCES = [
// Arcadia platform docs (resolved against ARCADIA = ../reference/arcadia-app).
{ path: "README.md", tags: ["core"] },
{ path: "docs/ARCADIA.md", tags: ["core"] },
{ path: "docs/MODULAR_MONOLITH.md", tags: ["core"] },
@@ -29,6 +30,16 @@ const SOURCES = [
{ path: "DEPLOY.md", tags: ["ops"] },
{ path: "DEV_DEPLOY.md", tags: ["ops"] },
{ path: "DEV_SETUP.md", tags: ["ops"] },
// RAG ecosystem docs — pulled from sibling repos via per-source
// rootDir override. Lets the assistant answer "how do I add a
// tenant to arcadia-search" or "what does the browser RAG do"
// without leaving the chat.
{ rootDir: "../../arcadia-search", path: "README.md", tags: ["rag", "search-service"] },
{ rootDir: "../../arcadia-search", path: "MULTI_TENANT.md", tags: ["rag", "search-service"] },
{ rootDir: "../../arcadia-search", path: "ARCADIA_INTEGRATION.md", tags: ["rag", "integration"] },
{ rootDir: "../../lib-lexical-rag-ui", path: "README.md", tags: ["rag", "browser"] },
{ rootDir: "../../arcadia-admin", path: "docs/RAG.md", tags: ["rag", "overview"] },
]
buildIndex({

84
scripts/mint-search-token.mjs Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Mint an HMAC-signed JWT for arcadia-search and (optionally) write it
// into .env.local as VITE_ARCADIA_SEARCH_TOKEN.
//
// arcadia-search expects:
// - HS512 by default when only JWT_HMAC_SECRET is set on the server
// (no PEM). HS256/HS384 work too if JWT_ALGORITHM is overridden.
// - a `tenant_id` claim (configurable server-side via JWT_TENANT_CLAIM).
//
// Usage:
// node scripts/mint-search-token.mjs # uses defaults, writes .env.local
// node scripts/mint-search-token.mjs --print # print only, don't write
// node scripts/mint-search-token.mjs --tenant=foo --days=30
// JWT_HMAC_SECRET=xyz node scripts/mint-search-token.mjs
import { createHmac } from "node:crypto"
import { readFileSync, writeFileSync, existsSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { dirname, resolve } from "node:path"
const HERE = dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = resolve(HERE, "..")
const ENV_LOCAL = resolve(PROJECT_ROOT, ".env.local")
function arg(name, fallback) {
const prefix = `--${name}=`
const hit = process.argv.find((a) => a.startsWith(prefix))
return hit ? hit.slice(prefix.length) : fallback
}
const flag = (name) => process.argv.includes(`--${name}`)
const SECRET = process.env.JWT_HMAC_SECRET ?? "test-secret-change-me"
const TENANT = arg("tenant", process.env.VITE_ARCADIA_TENANT ?? "platform-admin")
const SUBJECT = arg("sub", "arcadia-admin")
const ALGORITHM = (arg("alg", "HS512")).toUpperCase()
const DAYS = Number(arg("days", "365"))
const PRINT_ONLY = flag("print")
const HMAC_ALG = { HS256: "sha256", HS384: "sha384", HS512: "sha512" }[ALGORITHM]
if (!HMAC_ALG) {
console.error(`Unsupported algorithm "${ALGORITHM}". Use HS256, HS384, or HS512.`)
process.exit(1)
}
const b64 = (v) =>
Buffer.from(typeof v === "string" ? v : JSON.stringify(v)).toString("base64url")
const now = Math.floor(Date.now() / 1000)
const header = b64({ alg: ALGORITHM, typ: "JWT" })
const payload = b64({
sub: SUBJECT,
tenant_id: TENANT,
iat: now,
exp: now + DAYS * 86400,
})
const signature = createHmac(HMAC_ALG, SECRET)
.update(`${header}.${payload}`)
.digest("base64url")
const token = `${header}.${payload}.${signature}`
if (PRINT_ONLY) {
console.log(token)
process.exit(0)
}
// Upsert VITE_ARCADIA_SEARCH_TOKEN in .env.local without disturbing other keys.
const KEY = "VITE_ARCADIA_SEARCH_TOKEN"
let existing = ""
if (existsSync(ENV_LOCAL)) existing = readFileSync(ENV_LOCAL, "utf8")
const line = `${KEY}=${token}`
const next = new RegExp(`^${KEY}=.*$`, "m").test(existing)
? existing.replace(new RegExp(`^${KEY}=.*$`, "m"), line)
: (existing.endsWith("\n") || existing === "" ? existing : existing + "\n") + line + "\n"
writeFileSync(ENV_LOCAL, next)
console.log(`Wrote ${KEY} to ${ENV_LOCAL}`)
console.log(` alg: ${ALGORITHM}`)
console.log(` tenant: ${TENANT}`)
console.log(` sub: ${SUBJECT}`)
console.log(` expires: ${new Date((now + DAYS * 86400) * 1000).toISOString()}`)
console.log(` secret: ${SECRET === "test-secret-change-me" ? "(default — override with JWT_HMAC_SECRET=)" : "(from JWT_HMAC_SECRET)"}`)
console.log(``)
console.log(`Restart 'npm run dev' so Vite picks up the new env var.`)