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:
@@ -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:
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user