diff --git a/.gitignore b/.gitignore index b64188c..358ef59 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .DS_Store .env +.env.local +.env.*.local /node_modules/ # React Router diff --git a/README.md b/README.md index 2884406..e8986a3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/components/auth/auth-shell.tsx b/app/components/auth/auth-shell.tsx new file mode 100644 index 0000000..75346c0 --- /dev/null +++ b/app/components/auth/auth-shell.tsx @@ -0,0 +1,33 @@ +import { type ReactNode } from "react" + +import { useBrand } from "~/lib/identity" + +export function AuthShell({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +export function AuthBrand() { + const brand = useBrand() + const BrandIcon = brand.icon + return ( +
+ + + + {brand.name} +
+ ) +} diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index ef29412..efed172 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -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(() => { 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 (
{children}
diff --git a/app/components/layout/page-header.tsx b/app/components/layout/page-header.tsx new file mode 100644 index 0000000..0756b2b --- /dev/null +++ b/app/components/layout/page-header.tsx @@ -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 ( +
+
+

{title}

+ {badges} +
+ {description ? ( +

{description}

+ ) : null} + {actions ? ( +
{actions}
+ ) : null} +
+ ) +} diff --git a/app/components/scripts-dialog.tsx b/app/components/scripts-dialog.tsx index be92f1e..da44708 100644 --- a/app/components/scripts-dialog.tsx +++ b/app/components/scripts-dialog.tsx @@ -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" diff --git a/app/lib/admin-tools.ts b/app/lib/admin-tools.ts index 03e75d6..83b955e 100644 --- a/app/lib/admin-tools.ts +++ b/app/lib/admin-tools.ts @@ -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 }) + .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 { + 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 { 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 = {} + 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: diff --git a/app/lib/agents.ts b/app/lib/agents.ts index 0ff125d..6430347 100644 --- a/app/lib/agents.ts +++ b/app/lib/agents.ts @@ -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[] { diff --git a/app/lib/profile.ts b/app/lib/profile.ts index 6077098..9b7588a 100644 --- a/app/lib/profile.ts +++ b/app/lib/profile.ts @@ -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 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, diff --git a/app/lib/resources.test.ts b/app/lib/resources.test.ts deleted file mode 100644 index 8b61f83..0000000 --- a/app/lib/resources.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/app/lib/resources.ts b/app/lib/resources.ts deleted file mode 100644 index 2157604..0000000 --- a/app/lib/resources.ts +++ /dev/null @@ -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>, -): 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) -} diff --git a/app/lib/session.test.ts b/app/lib/session.test.ts index 8cf685c..bd7972b 100644 --- a/app/lib/session.test.ts +++ b/app/lib/session.test.ts @@ -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") }) }) diff --git a/app/lib/session.ts b/app/lib/session.ts index d1de13b..0fffaed 100644 --- a/app/lib/session.ts +++ b/app/lib/session.ts @@ -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 { - 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() diff --git a/app/root.tsx b/app/root.tsx index 8e7c096..b4b1d84 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -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() { > + diff --git a/app/routes.ts b/app/routes.ts index f709856..eecf4cc 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/routes/activity.tsx b/app/routes/activity.tsx index 29ea6a1..9b603ae 100644 --- a/app/routes/activity.tsx +++ b/app/routes/activity.tsx @@ -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 ( - -
- - - Sign in required - The audit log requires an admin session. - - - - - -
-
- ) - } - return ( - -
+ +

Audit log

diff --git a/app/routes/ai.tsx b/app/routes/ai.tsx index 10e5b28..eeda1ef 100644 --- a/app/routes/ai.tsx +++ b/app/routes/ai.tsx @@ -623,7 +623,7 @@ export default function AIRoute() { const availableModels = status.kind === "live" ? status.models : ["mock"] return ( - + {/* Console aesthetic is scoped to this wrapper only, so the appbar * and sidebar keep using the global skyrise tokens (light/dark diff --git a/app/routes/announcements.tsx b/app/routes/announcements.tsx index c380f15..6e4366e 100644 --- a/app/routes/announcements.tsx +++ b/app/routes/announcements.tsx @@ -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 ( - -
- - - Sign in required - Announcements require an admin session. - - - - - -
-
- ) - } - return ( - -
+ +

Announcements

diff --git a/app/routes/assistant.tsx b/app/routes/assistant.tsx index 2936357..2e49d39 100644 --- a/app/routes/assistant.tsx +++ b/app/routes/assistant.tsx @@ -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 ( - + -
- - - Sign in required - Bucket administration requires an admin session. - - - - - -
-
- ) - } - return ( - -
+ +

diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 2e816e3..27db105 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -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 ( - - - - Welcome - - A hybrid traditional + AI-first scaffold. Use the rail to navigate; - the Assistant can drive the UI on your behalf — try{" "} - - ⌘⇧P - {" "} - for the script runner. - - - + const session = useSession() + const arcadia = useArcadiaClient() -
- {tiles.map((t) => { - const Icon = t.icon - return ( + const [data, setData] = useState(EMPTY) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshedAt, setRefreshedAt] = useState(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 ( + + + Live snapshot of the platform — tenants, users, recent activity, and health. + {refreshedAt ? ( + <> + {" "} + Refreshed {refreshedAt.toLocaleTimeString()}. + + ) : null} + + } + actions={ + + } + /> + + {error ? ( + setError(null)}> + {error} + + ) : null} + +
+ + + 0 + ? `${stats.audit.errors} error${stats.audit.errors === 1 ? "" : "s"}` + : "no errors" + } + loading={loading} + tone={stats.audit.errors > 0 ? "warning" : "default"} + /> + +
+ +
+ + +
+ Recent activity + + Latest audit events across the platform. + +
- - -
- -
- - {t.title} - - - {t.body} -
-
+ View all → - ) - })} +
+ + + +
+ + + + Subsystems + + Live probe of each platform subsystem. + + + + + +
) } + +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 ( + + + +
+ + {label} + + +
+
+ + {loading ? ( + + ) : ( + {value} + )} + {loading ? ( + + ) : ( + {sub} + )} + +
+ + ) +} + +function RecentActivity({ logs, loading }: { logs: AuditLog[]; loading: boolean }) { + if (loading && logs.length === 0) { + return ( +

+ Loading… +

+ ) + } + if (logs.length === 0) { + return ( +

No recent events.

+ ) + } + return ( +
    + {logs.slice(0, 8).map((l) => ( +
  • +
    + + + {l.action} + + + {l.resource_type} + {l.resource_id ? ` · ${l.resource_id.slice(0, 8)}…` : ""} + + + + {l.user?.email ?? "system"} + +
    +
    + + +
    +
  • + ))} +
+ ) +} + +function SubsystemList({ + health, + loading, +}: { + health: OverallHealth | null + loading: boolean +}) { + if (loading && !health) { + return ( +

+ Probing… +

+ ) + } + if (!health) { + return ( +

+ Health endpoint unreachable. +

+ ) + } + return ( +
    + {SUBSYSTEMS.map((sys) => { + const sub = health.subsystems[sys] + return ( +
  • + {labelFor(sys)} + + + + {sub?.message ?? statusLabel(sub?.status ?? "unconfigured")} + + +
  • + ) + })} +
+ ) +} + +function SeverityDot({ severity }: { severity: string }) { + const tone = + severity === "critical" || severity === "error" + ? "bg-destructive" + : severity === "warning" + ? "bg-amber-500" + : "bg-emerald-500" + return ( + + ) +} + +function StatusIcon({ status }: { status: HealthStatus }) { + if (status === "ok") + return + if (status === "degraded") + return + if (status === "error") + return + return +} + +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` +} diff --git a/app/routes/library.tsx b/app/routes/library.tsx index 9dd8d36..dba06ee 100644 --- a/app/routes/library.tsx +++ b/app/routes/library.tsx @@ -38,7 +38,7 @@ export default function LibraryRoute() { const open = items.find((x) => x.id === openId) ?? null return ( - + Library diff --git a/app/routes/login.2fa.tsx b/app/routes/login.2fa.tsx new file mode 100644 index 0000000..f700c06 --- /dev/null +++ b/app/routes/login.2fa.tsx @@ -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 ( + +
+

Challenge missing

+

+ This page is only reachable after a sign-in attempt. Start over. +

+ +
+
+ ) + } + + return ( + + } + 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 }) + }} + /> + + ) +} diff --git a/app/routes/login.forgot.tsx b/app/routes/login.forgot.tsx new file mode 100644 index 0000000..485152b --- /dev/null +++ b/app/routes/login.forgot.tsx @@ -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(null) + + if (sentTo) { + return ( + +
+ +

Check your email

+

+ If an account exists for {sentTo}, we've sent a link + to reset your password. +

+ +
+
+ ) + } + + return ( + + } + onBack={() => navigate("/login")} + onSuccess={(email) => setSentTo(email)} + /> + + ) +} diff --git a/app/routes/login.reset.tsx b/app/routes/login.reset.tsx new file mode 100644 index 0000000..2f7b40e --- /dev/null +++ b/app/routes/login.reset.tsx @@ -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 ( + +
+

Reset link invalid

+

+ No token in the URL. Request a fresh password reset email. +

+ +
+
+ ) + } + + return ( + + } + token={token} + onSuccess={() => navigate("/login?reset=ok", { replace: true })} + /> + + ) +} diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 65c5289..301db91 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -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 ( -
+ - - - - {brand.name} -
- } + brand={} 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")} /> -
+ ) } diff --git a/app/routes/memberships.tsx b/app/routes/memberships.tsx index 70836ad..0870791 100644 --- a/app/routes/memberships.tsx +++ b/app/routes/memberships.tsx @@ -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 ( - -
- - - Sign in required - Membership management requires an admin session. - - - - - -
-
- ) - } - return ( - -
+ +

Memberships

diff --git a/app/routes/monitoring.tsx b/app/routes/monitoring.tsx index 7465080..3671013 100644 --- a/app/routes/monitoring.tsx +++ b/app/routes/monitoring.tsx @@ -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 ( - -
- - - Sign in required - Monitoring requires an admin session. - - - - - -
-
- ) - } - return ( - -
+ +

Server stats & health

diff --git a/app/routes/networking.tsx b/app/routes/networking.tsx index 8598223..c6f9e60 100644 --- a/app/routes/networking.tsx +++ b/app/routes/networking.tsx @@ -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 ( - -
- - - Sign in required - Networking requires an admin session. - - - - - -
-
- ) - } - return ( - -
+ +

Networking

diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index 5c2ac3c..002f344 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -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) - const [savedAt, setSavedAt] = useState(null) + + // Local preferences (avatar, title, bio, signature, default agent). + const [prefs, setPrefs] = useState(profile) + const [prefsSavedAt, setPrefsSavedAt] = useState(null) + useEffect(() => { + setPrefs(profile) + }, [profile]) + const prefsDirty = JSON.stringify(prefs) !== JSON.stringify(profile) + + // Arcadia account. + const [account, setAccount] = useState(null) + const [accountDraft, setAccountDraft] = useState({ + first_name: "", + last_name: "", + email: "", + }) + const [accountLoading, setAccountLoading] = useState(true) + const [accountSaving, setAccountSaving] = useState(false) + const [accountSavedAt, setAccountSavedAt] = useState(null) + const [accountError, setAccountError] = useState(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 ( - + - You + + Account + {account?.email_verified ? ( + Verified + ) : account ? ( + Unverified + ) : null} + {account?.status && account.status !== "active" ? ( + {account.status} + ) : null} + - 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. + {accountError ? ( + setAccountError(null)} + > + {accountError} + + ) : null} +
- {draft.avatarUrl ? ( - + {prefs.avatarUrl ? ( + ) : null} {initials} -
+
+ + {account?.full_name || accountDraft.email || "—"} + + {account ? ( + <> + + Tenant {account.tenant_id} · + ID {account.id} + + + Last sign-in{" "} + {account.last_sign_in_at + ? new Date(account.last_sign_in_at).toLocaleString() + : "—"} + + + ) : null} +
+
+ +
+ + + setAccountDraft((d) => ({ ...d, first_name: e.target.value })) + } + autoComplete="given-name" + disabled={accountLoading || accountSaving} + /> + + + + setAccountDraft((d) => ({ ...d, last_name: e.target.value })) + } + autoComplete="family-name" + disabled={accountLoading || accountSaving} + /> + + + + setAccountDraft((d) => ({ ...d, email: e.target.value })) + } + autoComplete="email" + disabled={accountLoading || accountSaving} + /> + +
+ +
+ + + + {accountSavedAt && !accountDirty && ( + + Saved. + + )} +
+ + + + + + Preferences + + Local-only settings stored in this browser — avatar, bio, signature, + and the assistant's default persona. + + + +
+ Avatar +
- {draft.avatarUrl && ( + {prefs.avatarUrl && ( )} - - PNG, JPG, or SVG. Stored locally as a data URL. -
+ + PNG, JPG, or SVG. Stored locally as a data URL. +
- - - setDraft((d) => ({ ...d, name: e.target.value })) - } - autoComplete="name" - /> - - - - setDraft((d) => ({ ...d, email: e.target.value })) - } - autoComplete="email" - /> - - setDraft((d) => ({ ...d, title: e.target.value })) + setPrefs((d) => ({ ...d, title: e.target.value })) } - placeholder="e.g. Product designer" + placeholder="e.g. Platform admin" /> - setDraft((d) => ({ ...d, defaultAgentId: "" })) + setPrefs((d) => ({ ...d, defaultAgentId: "" })) } - data-state={!draft.defaultAgentId ? "checked" : undefined} + data-state={!prefs.defaultAgentId ? "checked" : undefined} > First available @@ -183,10 +384,10 @@ export default function ProfileRoute() { - 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() { >