admin: completeness + UI consistency pass

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

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

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

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

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

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

View File

@@ -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: