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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
33
app/components/auth/auth-shell.tsx
Normal file
33
app/components/auth/auth-shell.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { useBrand } from "~/lib/identity"
|
||||
|
||||
export function AuthShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="dark relative isolate flex min-h-svh items-center justify-center p-4"
|
||||
style={{ background: "var(--background)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuthBrand() {
|
||||
const brand = useBrand()
|
||||
const BrandIcon = brand.icon
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
}}
|
||||
>
|
||||
<BrandIcon className="size-4" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{brand.name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -118,7 +118,6 @@ const navItems: NavItem[] = [
|
||||
]
|
||||
|
||||
type AppShellProps = {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
brand?: Brand
|
||||
user?: User
|
||||
@@ -131,7 +130,6 @@ type AppShellProps = {
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
title,
|
||||
children,
|
||||
brand: brandOverride,
|
||||
user: userOverride,
|
||||
@@ -143,14 +141,11 @@ export function AppShell({
|
||||
const session = useSession()
|
||||
const navigate = useNavigate()
|
||||
const brand = brandOverride ?? defaultBrand
|
||||
// Prefer the live session for identity, fall back to the editable profile,
|
||||
// fall back to the stub user.
|
||||
// Prefer the live session for identity, fall back to the stub user.
|
||||
const user = userOverride ?? {
|
||||
name: session?.name || profile.name || defaultUser.name,
|
||||
email: session?.email || profile.email || defaultUser.email,
|
||||
initials: profileInitials(
|
||||
session?.name || profile.name || defaultUser.name,
|
||||
),
|
||||
name: session?.name || defaultUser.name,
|
||||
email: session?.email || defaultUser.email,
|
||||
initials: profileInitials(session?.name || defaultUser.name),
|
||||
}
|
||||
|
||||
// Protected shell: bounce to /login when there's no session.
|
||||
@@ -163,7 +158,9 @@ export function AppShell({
|
||||
navigate(`/login?next=${next}`, { replace: true })
|
||||
}
|
||||
}, [session, navigate])
|
||||
if (!session) return null
|
||||
// All hooks must run unconditionally — keep them above the session
|
||||
// short-circuit so a sign-out doesn't reduce the hook count and trip
|
||||
// React's "rendered fewer hooks than expected" check.
|
||||
const [expanded, setExpanded] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false
|
||||
return localStorage.getItem(SIDEBAR_KEY) === "1"
|
||||
@@ -173,10 +170,11 @@ export function AppShell({
|
||||
}, [expanded])
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false)
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
useScriptsHotkey(() => setScriptsOpen(true))
|
||||
|
||||
if (!session) return null
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
data-theme={theme}
|
||||
@@ -389,7 +387,10 @@ export function AppShell({
|
||||
<div
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none"
|
||||
// First-child padding clears the fixed top-right floating actions
|
||||
// pill so page headers can put refresh/new buttons in their normal
|
||||
// top-right slot without sliding under the appbar avatar/controls.
|
||||
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none [&>*:first-child]:lg:pr-72"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
36
app/components/layout/page-header.tsx
Normal file
36
app/components/layout/page-header.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
/** Inline indicators after the title (badges, status pills). */
|
||||
badges?: ReactNode
|
||||
/** Toolbar rendered below the title row — primary actions go here. */
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
// Right-side space for the appbar's floating actions pill is reserved by
|
||||
// the AppShell's first-child padding rule, not here — keep this layout
|
||||
// concerned only with title/description/actions composition.
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
badges,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{badges}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
{actions ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">{actions}</div>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import type { Route } from "./+types/root"
|
||||
import "./app.css"
|
||||
|
||||
import { ToastProvider } from "@crema/notification-ui"
|
||||
import { ToastProvider, Toaster } from "@crema/notification-ui"
|
||||
import { CommandBusProvider } from "@crema/action-bus"
|
||||
import { ArcadiaProvider } from "@crema/arcadia-client"
|
||||
// CREMA:PROVIDERS-IMPORTS
|
||||
@@ -62,6 +62,7 @@ export default function App() {
|
||||
>
|
||||
<CommandBusProvider>
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
</CommandBusProvider>
|
||||
</ArcadiaProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { Activity, Eye, RefreshCw } from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
@@ -201,29 +200,9 @@ export default function ActivityRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Audit log">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>The audit log requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/activity">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Audit log">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>
|
||||
|
||||
@@ -623,7 +623,7 @@ export default function AIRoute() {
|
||||
const availableModels = status.kind === "live" ? status.models : ["mock"]
|
||||
|
||||
return (
|
||||
<AppShell title="AI">
|
||||
<AppShell>
|
||||
<LLMProvider adapter={adapter} model={activeModel}>
|
||||
{/* Console aesthetic is scoped to this wrapper only, so the appbar
|
||||
* and sidebar keep using the global skyrise tokens (light/dark
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Megaphone,
|
||||
@@ -232,29 +231,9 @@ export default function AnnouncementsRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Announcements">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>Announcements require an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/announcements">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Announcements">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Announcements</h1>
|
||||
|
||||
@@ -233,13 +233,13 @@ const mockAdapter = new MockLLM({
|
||||
},
|
||||
{
|
||||
matches: (req) =>
|
||||
/(take me to|open|navigate|go to).*(resources|library|settings|activity|assistant|overview|home)/i.test(
|
||||
/(take me to|open|navigate|go to).*(tenants|users|library|settings|activity|assistant|overview|home)/i.test(
|
||||
req.messages.at(-1)?.content ?? "",
|
||||
),
|
||||
response: [
|
||||
"On it.\n\n",
|
||||
"```action\n",
|
||||
"navigate /resources\n",
|
||||
"navigate /tenants\n",
|
||||
"```\n",
|
||||
],
|
||||
},
|
||||
@@ -411,7 +411,7 @@ export default function AssistantRoute() {
|
||||
status.kind === "live" ? model || status.models[0] : "mock"
|
||||
|
||||
return (
|
||||
<AppShell title="Assistant">
|
||||
<AppShell>
|
||||
<LLMProvider adapter={adapter} model={activeModel}>
|
||||
<AssistantSurface
|
||||
key={`${activeThreadId}-${compactNonce}`}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
@@ -197,29 +196,9 @@ export default function BucketsRoute() {
|
||||
)
|
||||
useRegisterAdminContext("buckets", summary)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Buckets">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>Bucket administration requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/buckets">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Buckets">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { ArrowRight, Sparkles, Boxes, Activity, BookOpen } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
HeartPulse,
|
||||
RefreshCw,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import { AlertBanner } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { PageHeader } from "~/components/layout/page-header"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Skeleton } from "~/components/ui/skeleton"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,88 +25,406 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { listAuditLogs, type AuditLog } from "~/lib/arcadia/audit-logs"
|
||||
import {
|
||||
getHealth,
|
||||
SUBSYSTEMS,
|
||||
type HealthStatus,
|
||||
type HealthSubsystem,
|
||||
type OverallHealth,
|
||||
} from "~/lib/arcadia/health"
|
||||
import { listTenants, type Tenant } from "~/lib/arcadia/tenants"
|
||||
import { listUsers, type User } from "~/lib/arcadia/users"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
|
||||
export const meta = () => pageTitle("Overview")
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
to: "/assistant",
|
||||
icon: Sparkles,
|
||||
title: "Assistant",
|
||||
body: "AI-first surface — chat, suggestions, and full UI control.",
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
to: "/resources",
|
||||
icon: Boxes,
|
||||
title: "Resources",
|
||||
body: "Traditional list + detail surface for managed entities.",
|
||||
},
|
||||
{
|
||||
to: "/activity",
|
||||
icon: Activity,
|
||||
title: "Activity",
|
||||
body: "Event stream and audit log.",
|
||||
},
|
||||
{
|
||||
to: "/library",
|
||||
icon: BookOpen,
|
||||
title: "Library",
|
||||
body: "Saved items, templates, reusable artifacts.",
|
||||
},
|
||||
]
|
||||
interface DashboardData {
|
||||
tenants: Tenant[]
|
||||
users: User[]
|
||||
audit: AuditLog[]
|
||||
health: OverallHealth | null
|
||||
}
|
||||
|
||||
const EMPTY: DashboardData = { tenants: [], users: [], audit: [], health: null }
|
||||
|
||||
export default function HomeRoute() {
|
||||
return (
|
||||
<AppShell title="Overview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome</CardTitle>
|
||||
<CardDescription>
|
||||
A hybrid traditional + AI-first scaffold. Use the rail to navigate;
|
||||
the Assistant can drive the UI on your behalf — try{" "}
|
||||
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
⌘⇧P
|
||||
</kbd>{" "}
|
||||
for the script runner.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
const [data, setData] = useState<DashboardData>(EMPTY)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
const [tenants, users, audit, health] = await Promise.all([
|
||||
listTenants(arcadia).catch((err) => {
|
||||
throw err
|
||||
}),
|
||||
listUsers(arcadia),
|
||||
listAuditLogs(arcadia, { limit: 10 }),
|
||||
getHealth(arcadia).catch(() => null),
|
||||
]).catch((err) => {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load overview.")
|
||||
return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null]
|
||||
})
|
||||
setData({ tenants, users, audit, health })
|
||||
setRefreshedAt(new Date())
|
||||
setLoading(false)
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const activeTenants = data.tenants.filter((t) => t.status === "active").length
|
||||
const activeUsers = data.users.filter((u) => u.status === "active").length
|
||||
const errorEvents = data.audit.filter(
|
||||
(a) => a.severity === "error" || a.severity === "critical",
|
||||
).length
|
||||
return {
|
||||
tenants: { total: data.tenants.length, active: activeTenants },
|
||||
users: { total: data.users.length, active: activeUsers },
|
||||
audit: { recent: data.audit.length, errors: errorEvents },
|
||||
health: data.health?.status ?? "unconfigured",
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useRegisterAdminContext("overview", stats)
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
description={
|
||||
<>
|
||||
Live snapshot of the platform — tenants, users, recent activity, and health.
|
||||
{refreshedAt ? (
|
||||
<>
|
||||
{" "}
|
||||
Refreshed {refreshedAt.toLocaleTimeString()}.
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
data-action="overview-refresh"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={loading ? "size-4 animate-spin" : "size-4"} />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatTile
|
||||
to="/tenants"
|
||||
dataAction="overview-tile-tenants"
|
||||
icon={Building2}
|
||||
label="Tenants"
|
||||
value={stats.tenants.total}
|
||||
sub={`${stats.tenants.active} active`}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatTile
|
||||
to="/users"
|
||||
dataAction="overview-tile-users"
|
||||
icon={UsersIcon}
|
||||
label="Users"
|
||||
value={stats.users.total}
|
||||
sub={`${stats.users.active} active`}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatTile
|
||||
to="/activity"
|
||||
dataAction="overview-tile-activity"
|
||||
icon={Activity}
|
||||
label="Recent events"
|
||||
value={stats.audit.recent}
|
||||
sub={
|
||||
stats.audit.errors > 0
|
||||
? `${stats.audit.errors} error${stats.audit.errors === 1 ? "" : "s"}`
|
||||
: "no errors"
|
||||
}
|
||||
loading={loading}
|
||||
tone={stats.audit.errors > 0 ? "warning" : "default"}
|
||||
/>
|
||||
<StatTile
|
||||
to="/monitoring"
|
||||
dataAction="overview-tile-health"
|
||||
icon={HeartPulse}
|
||||
label="Platform health"
|
||||
value={statusLabel(stats.health)}
|
||||
sub={data.health ? `as of ${new Date(data.health.checked_at).toLocaleTimeString()}` : "unreachable"}
|
||||
loading={loading}
|
||||
tone={statusTone(stats.health)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="flex-row items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Recent activity</CardTitle>
|
||||
<CardDescription>
|
||||
Latest audit events across the platform.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
data-action={`home-tile-${t.title.toLowerCase()}`}
|
||||
className="group block"
|
||||
to="/activity"
|
||||
data-action="overview-activity-all"
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Card
|
||||
className={[
|
||||
"h-full transition-colors",
|
||||
t.accent
|
||||
? "border-primary/30 bg-primary/5 hover:border-primary/50"
|
||||
: "hover:border-foreground/20",
|
||||
].join(" ")}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t.title}
|
||||
<ArrowRight className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</CardTitle>
|
||||
<CardDescription>{t.body}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
View all →
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecentActivity logs={data.audit} loading={loading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Subsystems</CardTitle>
|
||||
<CardDescription>
|
||||
Live probe of each platform subsystem.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SubsystemList health={data.health} loading={loading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
to,
|
||||
dataAction,
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
loading,
|
||||
tone = "default",
|
||||
}: {
|
||||
to: string
|
||||
dataAction: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: number | string
|
||||
sub: string
|
||||
loading: boolean
|
||||
tone?: "default" | "warning" | "error" | "ok"
|
||||
}) {
|
||||
const accent =
|
||||
tone === "error"
|
||||
? "border-destructive/40 bg-destructive/5"
|
||||
: tone === "warning"
|
||||
? "border-amber-500/40 bg-amber-500/5"
|
||||
: tone === "ok"
|
||||
? "border-emerald-500/40 bg-emerald-500/5"
|
||||
: ""
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
data-action={dataAction}
|
||||
className="group block focus:outline-none"
|
||||
>
|
||||
<Card className={`h-full transition-colors hover:border-foreground/20 ${accent}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-1">
|
||||
{loading ? (
|
||||
<Skeleton className="h-9 w-20" />
|
||||
) : (
|
||||
<span className="text-3xl font-semibold tabular-nums">{value}</span>
|
||||
)}
|
||||
{loading ? (
|
||||
<Skeleton className="h-3 w-24" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{sub}</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentActivity({ logs, loading }: { logs: AuditLog[]; loading: boolean }) {
|
||||
if (loading && logs.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">No recent events.</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className="flex flex-col divide-y">
|
||||
{logs.slice(0, 8).map((l) => (
|
||||
<li
|
||||
key={l.id}
|
||||
className="flex items-start justify-between gap-3 py-2.5 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{l.action}
|
||||
</code>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{l.resource_type}
|
||||
{l.resource_id ? ` · ${l.resource_id.slice(0, 8)}…` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{l.user?.email ?? "system"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<SeverityDot severity={l.severity} />
|
||||
<time
|
||||
className="text-[11px] text-muted-foreground"
|
||||
dateTime={l.inserted_at}
|
||||
>
|
||||
{timeAgo(l.inserted_at)}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function SubsystemList({
|
||||
health,
|
||||
loading,
|
||||
}: {
|
||||
health: OverallHealth | null
|
||||
loading: boolean
|
||||
}) {
|
||||
if (loading && !health) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Probing…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (!health) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
Health endpoint unreachable.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className="flex flex-col divide-y">
|
||||
{SUBSYSTEMS.map((sys) => {
|
||||
const sub = health.subsystems[sys]
|
||||
return (
|
||||
<li
|
||||
key={sys}
|
||||
className="flex items-center justify-between gap-3 py-2.5 text-sm"
|
||||
>
|
||||
<span className="font-medium capitalize">{labelFor(sys)}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusIcon status={sub?.status ?? "unconfigured"} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sub?.message ?? statusLabel(sub?.status ?? "unconfigured")}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityDot({ severity }: { severity: string }) {
|
||||
const tone =
|
||||
severity === "critical" || severity === "error"
|
||||
? "bg-destructive"
|
||||
: severity === "warning"
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500"
|
||||
return (
|
||||
<span
|
||||
aria-label={severity}
|
||||
title={severity}
|
||||
className={`size-2 rounded-full ${tone}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: HealthStatus }) {
|
||||
if (status === "ok")
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />
|
||||
if (status === "degraded")
|
||||
return <AlertTriangle className="size-4 text-amber-500" aria-label="degraded" />
|
||||
if (status === "error")
|
||||
return <CircleAlert className="size-4 text-destructive" aria-label="error" />
|
||||
return <CircleAlert className="size-4 text-muted-foreground" aria-label="unconfigured" />
|
||||
}
|
||||
|
||||
function labelFor(sys: HealthSubsystem): string {
|
||||
if (sys === "api") return "API"
|
||||
if (sys === "db") return "Database"
|
||||
return sys
|
||||
}
|
||||
|
||||
function statusLabel(status: HealthStatus | string): string {
|
||||
if (status === "ok") return "Healthy"
|
||||
if (status === "degraded") return "Degraded"
|
||||
if (status === "error") return "Down"
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
function statusTone(status: HealthStatus | string): "default" | "ok" | "warning" | "error" {
|
||||
if (status === "ok") return "ok"
|
||||
if (status === "degraded") return "warning"
|
||||
if (status === "error") return "error"
|
||||
return "default"
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const t = new Date(iso).getTime()
|
||||
if (Number.isNaN(t)) return ""
|
||||
const diff = Date.now() - t
|
||||
const sec = Math.round(diff / 1000)
|
||||
if (sec < 60) return `${sec}s ago`
|
||||
const min = Math.round(sec / 60)
|
||||
if (min < 60) return `${min}m ago`
|
||||
const hr = Math.round(min / 60)
|
||||
if (hr < 24) return `${hr}h ago`
|
||||
const d = Math.round(hr / 24)
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function LibraryRoute() {
|
||||
const open = items.find((x) => x.id === openId) ?? null
|
||||
|
||||
return (
|
||||
<AppShell title="Library">
|
||||
<AppShell>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Library</CardTitle>
|
||||
|
||||
61
app/routes/login.2fa.tsx
Normal file
61
app/routes/login.2fa.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate, useSearchParams } from "react-router"
|
||||
|
||||
import { TwoFactorChallengeForm } from "@crema/arcadia-auth-ui"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { persistFromArcadiaLogin } from "~/lib/session"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Two-factor verification")
|
||||
|
||||
export default function TwoFactorRoute() {
|
||||
const [params] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const challenge = params.get("challenge") ?? ""
|
||||
const next = params.get("next") || "/"
|
||||
const [mode, setMode] = useState<"totp" | "recovery">("totp")
|
||||
|
||||
if (!challenge) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<h1 className="text-base font-semibold">Challenge missing</h1>
|
||||
<p className="text-muted-foreground">
|
||||
This page is only reachable after a sign-in attempt. Start over.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
data-action="2fa-back-to-login"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<TwoFactorChallengeForm
|
||||
brand={<AuthBrand />}
|
||||
challenge={challenge}
|
||||
mode={mode}
|
||||
onUseRecoveryCode={mode === "totp" ? () => setMode("recovery") : undefined}
|
||||
onBack={
|
||||
mode === "recovery"
|
||||
? () => setMode("totp")
|
||||
: () => navigate("/login")
|
||||
}
|
||||
onSuccess={({ tokens, user }) => {
|
||||
persistFromArcadiaLogin(tokens, user)
|
||||
navigate(next, { replace: true })
|
||||
}}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
50
app/routes/login.forgot.tsx
Normal file
50
app/routes/login.forgot.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { PasswordResetRequestForm } from "@crema/arcadia-auth-ui"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Reset password")
|
||||
|
||||
export default function ForgotPasswordRoute() {
|
||||
const navigate = useNavigate()
|
||||
const [sentTo, setSentTo] = useState<string | null>(null)
|
||||
|
||||
if (sentTo) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<CheckCircle2 className="size-8 text-emerald-500" />
|
||||
<h1 className="text-base font-semibold">Check your email</h1>
|
||||
<p className="text-muted-foreground">
|
||||
If an account exists for <strong>{sentTo}</strong>, we've sent a link
|
||||
to reset your password.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
data-action="forgot-back-to-login"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<PasswordResetRequestForm
|
||||
brand={<AuthBrand />}
|
||||
onBack={() => navigate("/login")}
|
||||
onSuccess={(email) => setSentTo(email)}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
47
app/routes/login.reset.tsx
Normal file
47
app/routes/login.reset.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useNavigate, useSearchParams } from "react-router"
|
||||
|
||||
import { PasswordResetConfirmForm } from "@crema/arcadia-auth-ui"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Set new password")
|
||||
|
||||
export default function ResetPasswordRoute() {
|
||||
const [params] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const token = params.get("token") ?? ""
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<h1 className="text-base font-semibold">Reset link invalid</h1>
|
||||
<p className="text-muted-foreground">
|
||||
No token in the URL. Request a fresh password reset email.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login/forgot")}
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
data-action="reset-request-new"
|
||||
>
|
||||
Request a new link
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<PasswordResetConfirmForm
|
||||
brand={<AuthBrand />}
|
||||
token={token}
|
||||
onSuccess={() => navigate("/login?reset=ok", { replace: true })}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { LoginForm } from "@crema/arcadia-auth-ui"
|
||||
import { useBrand } from "~/lib/identity"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession, persistFromArcadiaLogin } from "~/lib/session"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Sign in")
|
||||
|
||||
@@ -13,40 +14,24 @@ export default function LoginRoute() {
|
||||
const [params] = useSearchParams()
|
||||
const session = useSession()
|
||||
const brand = useBrand()
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
const next = params.get("next") || "/"
|
||||
|
||||
// Already signed in? Bounce.
|
||||
useEffect(() => {
|
||||
if (session) navigate(next, { replace: true })
|
||||
}, [session, next, navigate])
|
||||
|
||||
return (
|
||||
<div
|
||||
// Force dark mode on the login page regardless of the operator's
|
||||
// saved theme preference. Scoped to this wrapper (not documentElement)
|
||||
// so navigating away after sign-in restores their preferred mode.
|
||||
className="dark relative isolate flex min-h-svh items-center justify-center p-4"
|
||||
style={{ background: "var(--background)" }}
|
||||
>
|
||||
<AuthShell>
|
||||
<LoginForm
|
||||
brand={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg"
|
||||
style={{ background: "var(--primary)", color: "var(--primary-foreground)" }}
|
||||
>
|
||||
<BrandIcon className="size-4" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{brand.name}</span>
|
||||
</div>
|
||||
}
|
||||
brand={<AuthBrand />}
|
||||
heading={`Sign in to ${brand.name}`}
|
||||
subhead="Use your arcadia credentials. In dev seeds: admin@example.com / AdminP@ssw0rd."
|
||||
onSuccess={async ({ tokens, user, twoFactorRequired, twoFactorChallenge }) => {
|
||||
if (twoFactorRequired && twoFactorChallenge) {
|
||||
navigate(`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`)
|
||||
navigate(
|
||||
`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
persistFromArcadiaLogin(tokens, user)
|
||||
@@ -55,6 +40,6 @@ export default function LoginRoute() {
|
||||
onForgotPassword={() => navigate("/login/forgot")}
|
||||
onSignup={() => navigate("/signup")}
|
||||
/>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Network,
|
||||
@@ -286,29 +285,9 @@ export default function MembershipsRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Memberships">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>Membership management requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/memberships">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Memberships">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Memberships</h1>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
@@ -196,29 +195,9 @@ export default function MonitoringRoute() {
|
||||
)
|
||||
useRegisterAdminContext("monitoring", summary)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Monitoring">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>Monitoring requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/monitoring">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Monitoring">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Server stats & health</h1>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
@@ -116,29 +115,9 @@ export default function NetworkingRoute() {
|
||||
droplets: droplets.length,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Networking">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>Networking requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/networking">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Networking">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Networking</h1>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Check, Trash2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Check, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import { AlertBanner } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -22,72 +26,290 @@ import { Textarea } from "~/components/ui/textarea"
|
||||
import { useAgents } from "~/lib/agents"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import {
|
||||
DEFAULT_PROFILE,
|
||||
profileInitials,
|
||||
resetProfile,
|
||||
saveProfile,
|
||||
useProfile,
|
||||
type Profile,
|
||||
} from "~/lib/profile"
|
||||
import { getUser, updateUser, type User } from "~/lib/arcadia/users"
|
||||
import { updateSessionUser, useSession } from "~/lib/session"
|
||||
|
||||
export const meta = () => pageTitle("Profile")
|
||||
|
||||
interface AccountDraft {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function ProfileRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
const profile = useProfile()
|
||||
const agents = useAgents()
|
||||
const [draft, setDraft] = useState<Profile>(profile)
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||||
|
||||
// Local preferences (avatar, title, bio, signature, default agent).
|
||||
const [prefs, setPrefs] = useState<Profile>(profile)
|
||||
const [prefsSavedAt, setPrefsSavedAt] = useState<number | null>(null)
|
||||
useEffect(() => {
|
||||
setPrefs(profile)
|
||||
}, [profile])
|
||||
const prefsDirty = JSON.stringify(prefs) !== JSON.stringify(profile)
|
||||
|
||||
// Arcadia account.
|
||||
const [account, setAccount] = useState<User | null>(null)
|
||||
const [accountDraft, setAccountDraft] = useState<AccountDraft>({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
})
|
||||
const [accountLoading, setAccountLoading] = useState(true)
|
||||
const [accountSaving, setAccountSaving] = useState(false)
|
||||
const [accountSavedAt, setAccountSavedAt] = useState<number | null>(null)
|
||||
const [accountError, setAccountError] = useState<string | null>(null)
|
||||
|
||||
const loadAccount = useCallback(async () => {
|
||||
if (!session) return
|
||||
setAccountLoading(true)
|
||||
setAccountError(null)
|
||||
try {
|
||||
const u = await getUser(arcadia, session.userId)
|
||||
setAccount(u)
|
||||
setAccountDraft({
|
||||
first_name: u.first_name ?? "",
|
||||
last_name: u.last_name ?? "",
|
||||
email: u.email,
|
||||
})
|
||||
} catch (err) {
|
||||
setAccountError(
|
||||
err instanceof ArcadiaError ? err.message : "Failed to load account.",
|
||||
)
|
||||
} finally {
|
||||
setAccountLoading(false)
|
||||
}
|
||||
}, [arcadia, session])
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(profile)
|
||||
}, [profile])
|
||||
loadAccount()
|
||||
}, [loadAccount])
|
||||
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(profile)
|
||||
const initials = profileInitials(draft.name || DEFAULT_PROFILE.name)
|
||||
const accountDirty =
|
||||
!!account &&
|
||||
(accountDraft.first_name !== (account.first_name ?? "") ||
|
||||
accountDraft.last_name !== (account.last_name ?? "") ||
|
||||
accountDraft.email !== account.email)
|
||||
|
||||
const saveAccount = async () => {
|
||||
if (!account) return
|
||||
setAccountSaving(true)
|
||||
setAccountError(null)
|
||||
try {
|
||||
const updated = await updateUser(arcadia, account.id, {
|
||||
first_name: accountDraft.first_name || null,
|
||||
last_name: accountDraft.last_name || null,
|
||||
email: accountDraft.email,
|
||||
})
|
||||
setAccount(updated)
|
||||
updateSessionUser({ name: updated.full_name, email: updated.email })
|
||||
setAccountSavedAt(Date.now())
|
||||
} catch (err) {
|
||||
setAccountError(
|
||||
err instanceof ArcadiaError ? err.message : "Save failed.",
|
||||
)
|
||||
} finally {
|
||||
setAccountSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Local prefs handlers.
|
||||
const initials = profileInitials(
|
||||
[accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") ||
|
||||
account?.full_name ||
|
||||
session?.name ||
|
||||
"",
|
||||
)
|
||||
|
||||
const onPickAvatar = (file: File | null) => {
|
||||
if (!file) {
|
||||
setDraft((d) => ({ ...d, avatarUrl: "" }))
|
||||
setPrefs((d) => ({ ...d, avatarUrl: "" }))
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (typeof result === "string")
|
||||
setDraft((d) => ({ ...d, avatarUrl: result }))
|
||||
setPrefs((d) => ({ ...d, avatarUrl: result }))
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
saveProfile(draft)
|
||||
setSavedAt(Date.now())
|
||||
const savePrefs = () => {
|
||||
saveProfile(prefs)
|
||||
setPrefsSavedAt(Date.now())
|
||||
}
|
||||
|
||||
const defaultAgent =
|
||||
agents.find((a) => a.id === draft.defaultAgentId) ?? null
|
||||
const defaultAgent = agents.find((a) => a.id === prefs.defaultAgentId) ?? null
|
||||
|
||||
return (
|
||||
<AppShell title="Profile">
|
||||
<AppShell>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>You</CardTitle>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
Account
|
||||
{account?.email_verified ? (
|
||||
<Badge variant="default">Verified</Badge>
|
||||
) : account ? (
|
||||
<Badge variant="secondary">Unverified</Badge>
|
||||
) : null}
|
||||
{account?.status && account.status !== "active" ? (
|
||||
<Badge variant="destructive">{account.status}</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Personal info shown across the app — appbar avatar, signatures, and
|
||||
anywhere the assistant references you.
|
||||
Your arcadia identity. Changes are saved to the platform and reflected
|
||||
anywhere your name or email appears.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
{accountError ? (
|
||||
<AlertBanner
|
||||
variant="error"
|
||||
dismissible
|
||||
onDismiss={() => setAccountError(null)}
|
||||
>
|
||||
{accountError}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Avatar className="size-20 ring-2 ring-primary/30">
|
||||
{draft.avatarUrl ? (
|
||||
<AvatarImage src={draft.avatarUrl} alt={draft.name} />
|
||||
{prefs.avatarUrl ? (
|
||||
<AvatarImage src={prefs.avatarUrl} alt={accountDraft.email} />
|
||||
) : null}
|
||||
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">
|
||||
{account?.full_name || accountDraft.email || "—"}
|
||||
</span>
|
||||
{account ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tenant <code className="font-mono">{account.tenant_id}</code> ·
|
||||
ID <code className="font-mono">{account.id}</code>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last sign-in{" "}
|
||||
{account.last_sign_in_at
|
||||
? new Date(account.last_sign_in_at).toLocaleString()
|
||||
: "—"}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="First name">
|
||||
<Input
|
||||
data-action="profile-first-name"
|
||||
value={accountDraft.first_name}
|
||||
onChange={(e) =>
|
||||
setAccountDraft((d) => ({ ...d, first_name: e.target.value }))
|
||||
}
|
||||
autoComplete="given-name"
|
||||
disabled={accountLoading || accountSaving}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Last name">
|
||||
<Input
|
||||
data-action="profile-last-name"
|
||||
value={accountDraft.last_name}
|
||||
onChange={(e) =>
|
||||
setAccountDraft((d) => ({ ...d, last_name: e.target.value }))
|
||||
}
|
||||
autoComplete="family-name"
|
||||
disabled={accountLoading || accountSaving}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Email"
|
||||
hint="Updating your email may require re-verification."
|
||||
>
|
||||
<Input
|
||||
data-action="profile-email"
|
||||
type="email"
|
||||
value={accountDraft.email}
|
||||
onChange={(e) =>
|
||||
setAccountDraft((d) => ({ ...d, email: e.target.value }))
|
||||
}
|
||||
autoComplete="email"
|
||||
disabled={accountLoading || accountSaving}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
data-action="profile-account-save"
|
||||
onClick={saveAccount}
|
||||
disabled={!accountDirty || accountSaving || accountLoading}
|
||||
>
|
||||
{accountSaving ? (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Save account
|
||||
</Button>
|
||||
<Button
|
||||
data-action="profile-account-revert"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!account) return
|
||||
setAccountDraft({
|
||||
first_name: account.first_name ?? "",
|
||||
last_name: account.last_name ?? "",
|
||||
email: account.email,
|
||||
})
|
||||
}}
|
||||
disabled={!accountDirty || accountSaving}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
<Button
|
||||
data-action="profile-account-refresh"
|
||||
variant="ghost"
|
||||
onClick={loadAccount}
|
||||
disabled={accountLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={accountLoading ? "size-4 animate-spin" : "size-4"}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
{accountSavedAt && !accountDirty && (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<Check className="size-4" /> Saved.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preferences</CardTitle>
|
||||
<CardDescription>
|
||||
Local-only settings stored in this browser — avatar, bio, signature,
|
||||
and the assistant's default persona.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">Avatar</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="inline-flex w-fit cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground">
|
||||
<input
|
||||
data-action="profile-avatar-upload"
|
||||
@@ -98,53 +320,32 @@ export default function ProfileRoute() {
|
||||
/>
|
||||
Upload avatar
|
||||
</label>
|
||||
{draft.avatarUrl && (
|
||||
{prefs.avatarUrl && (
|
||||
<Button
|
||||
data-action="profile-avatar-remove"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPickAvatar(null)}
|
||||
className="w-fit text-muted-foreground"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Trash2 className="size-3.5" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
PNG, JPG, or SVG. Stored locally as a data URL.
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
PNG, JPG, or SVG. Stored locally as a data URL.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Name">
|
||||
<Input
|
||||
data-action="profile-name"
|
||||
value={draft.name}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, name: e.target.value }))
|
||||
}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<Input
|
||||
data-action="profile-email"
|
||||
type="email"
|
||||
value={draft.email}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, email: e.target.value }))
|
||||
}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Title" hint="Your role at work.">
|
||||
<Input
|
||||
data-action="profile-title"
|
||||
value={draft.title}
|
||||
value={prefs.title}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, title: e.target.value }))
|
||||
setPrefs((d) => ({ ...d, title: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. Product designer"
|
||||
placeholder="e.g. Platform admin"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -173,9 +374,9 @@ export default function ProfileRoute() {
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setDraft((d) => ({ ...d, defaultAgentId: "" }))
|
||||
setPrefs((d) => ({ ...d, defaultAgentId: "" }))
|
||||
}
|
||||
data-state={!draft.defaultAgentId ? "checked" : undefined}
|
||||
data-state={!prefs.defaultAgentId ? "checked" : undefined}
|
||||
>
|
||||
First available
|
||||
</DropdownMenuItem>
|
||||
@@ -183,10 +384,10 @@ export default function ProfileRoute() {
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onClick={() =>
|
||||
setDraft((d) => ({ ...d, defaultAgentId: a.id }))
|
||||
setPrefs((d) => ({ ...d, defaultAgentId: a.id }))
|
||||
}
|
||||
data-state={
|
||||
draft.defaultAgentId === a.id ? "checked" : undefined
|
||||
prefs.defaultAgentId === a.id ? "checked" : undefined
|
||||
}
|
||||
className="flex flex-col items-start"
|
||||
>
|
||||
@@ -207,10 +408,8 @@ export default function ProfileRoute() {
|
||||
>
|
||||
<Textarea
|
||||
data-action="profile-bio"
|
||||
value={draft.bio}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, bio: e.target.value }))
|
||||
}
|
||||
value={prefs.bio}
|
||||
onChange={(e) => setPrefs((d) => ({ ...d, bio: e.target.value }))}
|
||||
rows={3}
|
||||
placeholder="Tell the assistant about you."
|
||||
/>
|
||||
@@ -222,42 +421,42 @@ export default function ProfileRoute() {
|
||||
>
|
||||
<Textarea
|
||||
data-action="profile-signature"
|
||||
value={draft.signature}
|
||||
value={prefs.signature}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, signature: e.target.value }))
|
||||
setPrefs((d) => ({ ...d, signature: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
placeholder={`Cheers,\n${draft.name || "Your name"}`}
|
||||
placeholder={`Cheers,\n${account?.full_name || "Your name"}`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
data-action="profile-save"
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
data-action="profile-prefs-save"
|
||||
onClick={savePrefs}
|
||||
disabled={!prefsDirty}
|
||||
>
|
||||
Save
|
||||
Save preferences
|
||||
</Button>
|
||||
<Button
|
||||
data-action="profile-revert"
|
||||
data-action="profile-prefs-revert"
|
||||
variant="ghost"
|
||||
onClick={() => setDraft(profile)}
|
||||
disabled={!dirty}
|
||||
onClick={() => setPrefs(profile)}
|
||||
disabled={!prefsDirty}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
<Button
|
||||
data-action="profile-reset"
|
||||
data-action="profile-prefs-reset"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
resetProfile()
|
||||
setSavedAt(Date.now())
|
||||
setPrefsSavedAt(Date.now())
|
||||
}}
|
||||
>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
{savedAt && !dirty && (
|
||||
{prefsSavedAt && !prefsDirty && (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<Check className="size-4" /> Saved.
|
||||
</span>
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Plus, Search, Trash2 } from "lucide-react"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import {
|
||||
createResource,
|
||||
deleteResource,
|
||||
seedResourcesIfEmpty,
|
||||
updateResource,
|
||||
useResources,
|
||||
type Resource,
|
||||
} from "~/lib/resources"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
|
||||
export const meta = () => pageTitle("Resources")
|
||||
|
||||
const statuses: Resource["status"][] = ["active", "paused", "archived"]
|
||||
|
||||
export default function ResourcesRoute() {
|
||||
const items = useResources()
|
||||
const [query, setQuery] = useState("")
|
||||
const [draftName, setDraftName] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
seedResourcesIfEmpty()
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return q
|
||||
? items.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.owner.toLowerCase().includes(q) ||
|
||||
r.status.includes(q),
|
||||
)
|
||||
: items
|
||||
}, [items, query])
|
||||
|
||||
const create = () => {
|
||||
const name = draftName.trim()
|
||||
if (!name) return
|
||||
createResource({ name, owner: "You" })
|
||||
setDraftName("")
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Resources">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resources</CardTitle>
|
||||
<CardDescription>
|
||||
Example domain entity. CRUD goes through{" "}
|
||||
<code className="font-mono text-xs">~/lib/resources.ts</code> —
|
||||
swap that file's calls for{" "}
|
||||
<code className="font-mono text-xs">api.get/post/put/del</code>{" "}
|
||||
from <code className="font-mono text-xs">~/lib/api.ts</code> when
|
||||
you have a backend.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
data-action="resources-search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search name, owner, status…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
data-action="resources-new-name"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") create()
|
||||
}}
|
||||
placeholder="New resource name…"
|
||||
className="max-w-64"
|
||||
/>
|
||||
<Button
|
||||
data-action="resources-create"
|
||||
onClick={create}
|
||||
disabled={!draftName.trim()}
|
||||
>
|
||||
<Plus className="size-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-card/40">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Owner</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Updated</th>
|
||||
<th className="w-10 px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-3 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{items.length === 0
|
||||
? "No resources yet — add one above."
|
||||
: "No matches."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="border-t transition-colors hover:bg-accent/30"
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{r.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{r.owner}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
data-action={`resources-status-${r.id}`}
|
||||
value={r.status}
|
||||
onChange={(e) =>
|
||||
updateResource(r.id, {
|
||||
status: e.target.value as Resource["status"],
|
||||
})
|
||||
}
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{statuses.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
|
||||
{new Date(r.updatedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button
|
||||
data-action={`resources-delete-${r.id}`}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${r.name}"?`))
|
||||
deleteResource(r.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{items.length} total · {filtered.length} shown
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
@@ -241,31 +240,9 @@ export default function ScheduledTasksRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Scheduled tasks">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>
|
||||
Scheduled task administration requires an admin session.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/scheduled-tasks">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Scheduled tasks">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Scheduled tasks</h1>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
@@ -248,31 +247,9 @@ export default function SecretsRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Secrets">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>
|
||||
Secrets administration requires an admin session.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/secrets">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Secrets">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Secrets</h1>
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function SettingsRoute() {
|
||||
}, [section])
|
||||
|
||||
return (
|
||||
<AppShell title="Settings">
|
||||
<AppShell>
|
||||
<div className="grid gap-6 md:grid-cols-[14rem_1fr]">
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
|
||||
44
app/routes/signup.tsx
Normal file
44
app/routes/signup.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
|
||||
import { SignupForm } from "@crema/arcadia-auth-ui"
|
||||
import { useBrand } from "~/lib/identity"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { persistFromArcadiaLogin, useSession } from "~/lib/session"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Create account")
|
||||
|
||||
export default function SignupRoute() {
|
||||
const navigate = useNavigate()
|
||||
const session = useSession()
|
||||
const brand = useBrand()
|
||||
|
||||
useEffect(() => {
|
||||
if (session) navigate("/", { replace: true })
|
||||
}, [session, navigate])
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<SignupForm
|
||||
brand={<AuthBrand />}
|
||||
heading={`Join ${brand.name}`}
|
||||
onSignin={() => navigate("/login")}
|
||||
onSuccess={async ({ tokens, user, emailVerificationSent }) => {
|
||||
if (tokens) {
|
||||
persistFromArcadiaLogin(tokens, user)
|
||||
navigate("/", { replace: true })
|
||||
return
|
||||
}
|
||||
// No tokens returned — verification email gating. Bounce to login.
|
||||
navigate(
|
||||
emailVerificationSent
|
||||
? "/login?verify=sent"
|
||||
: "/login",
|
||||
{ replace: true },
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
@@ -209,29 +208,9 @@ export default function SsoRoute() {
|
||||
initialPageSize: 25,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="SSO">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>SSO administration requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/sso">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="SSO">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Single sign-on</h1>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
@@ -172,29 +171,9 @@ export default function StatusPageRoute() {
|
||||
[uiComponents],
|
||||
)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Status page">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>Status page admin requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/status-page">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Status page">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Status page</h1>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CheckCircle2,
|
||||
HardDrive,
|
||||
@@ -264,31 +263,9 @@ export default function StorageRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Storage">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>
|
||||
Storage administration requires an admin session.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/storage">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Storage">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Storage</h1>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { Pause, Play, Plus, RefreshCw } from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
@@ -18,6 +17,7 @@ import { SearchInput } from "@crema/search-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { PageHeader } from "~/components/layout/page-header"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -174,39 +174,13 @@ export default function TenantsRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Tenants">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>
|
||||
Tenant administration requires an admin session.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/tenants">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Tenants">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Tenants</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Multi-tenant workspaces on this arcadia deployment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppShell>
|
||||
<PageHeader
|
||||
title="Tenants"
|
||||
description="Multi-tenant workspaces on this arcadia deployment."
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -221,8 +195,9 @@ export default function TenantsRoute() {
|
||||
<Plus className="size-4" />
|
||||
New tenant
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
@@ -276,7 +251,6 @@ export default function TenantsRoute() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pending?.kind === "suspend"}
|
||||
|
||||
@@ -169,29 +169,9 @@ export default function UsersRoute() {
|
||||
)
|
||||
useRegisterAdminContext("users", summary)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Users">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>User administration requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/users">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Users">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Users</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
@@ -242,29 +241,9 @@ export default function WebhooksRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Webhooks">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>Webhook administration requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/webhooks">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Webhooks">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Webhooks</h1>
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"sync-libs": "node scripts/sync-libs.mjs",
|
||||
"build:docs": "node scripts/build-docs-index.mjs"
|
||||
"build:docs": "node scripts/build-docs-index.mjs",
|
||||
"mint:search-token": "node scripts/mint-search-token.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
|
||||
@@ -22,6 +22,7 @@ const ARCADIA = resolve(ROOT, "../reference/arcadia-app")
|
||||
const OUT = resolve(ROOT, "public/docs-index.json")
|
||||
|
||||
const SOURCES = [
|
||||
// Arcadia platform docs (resolved against ARCADIA = ../reference/arcadia-app).
|
||||
{ path: "README.md", tags: ["core"] },
|
||||
{ path: "docs/ARCADIA.md", tags: ["core"] },
|
||||
{ path: "docs/MODULAR_MONOLITH.md", tags: ["core"] },
|
||||
@@ -29,6 +30,16 @@ const SOURCES = [
|
||||
{ path: "DEPLOY.md", tags: ["ops"] },
|
||||
{ path: "DEV_DEPLOY.md", tags: ["ops"] },
|
||||
{ path: "DEV_SETUP.md", tags: ["ops"] },
|
||||
|
||||
// RAG ecosystem docs — pulled from sibling repos via per-source
|
||||
// rootDir override. Lets the assistant answer "how do I add a
|
||||
// tenant to arcadia-search" or "what does the browser RAG do"
|
||||
// without leaving the chat.
|
||||
{ rootDir: "../../arcadia-search", path: "README.md", tags: ["rag", "search-service"] },
|
||||
{ rootDir: "../../arcadia-search", path: "MULTI_TENANT.md", tags: ["rag", "search-service"] },
|
||||
{ rootDir: "../../arcadia-search", path: "ARCADIA_INTEGRATION.md", tags: ["rag", "integration"] },
|
||||
{ rootDir: "../../lib-lexical-rag-ui", path: "README.md", tags: ["rag", "browser"] },
|
||||
{ rootDir: "../../arcadia-admin", path: "docs/RAG.md", tags: ["rag", "overview"] },
|
||||
]
|
||||
|
||||
buildIndex({
|
||||
|
||||
84
scripts/mint-search-token.mjs
Executable file
84
scripts/mint-search-token.mjs
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
// Mint an HMAC-signed JWT for arcadia-search and (optionally) write it
|
||||
// into .env.local as VITE_ARCADIA_SEARCH_TOKEN.
|
||||
//
|
||||
// arcadia-search expects:
|
||||
// - HS512 by default when only JWT_HMAC_SECRET is set on the server
|
||||
// (no PEM). HS256/HS384 work too if JWT_ALGORITHM is overridden.
|
||||
// - a `tenant_id` claim (configurable server-side via JWT_TENANT_CLAIM).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/mint-search-token.mjs # uses defaults, writes .env.local
|
||||
// node scripts/mint-search-token.mjs --print # print only, don't write
|
||||
// node scripts/mint-search-token.mjs --tenant=foo --days=30
|
||||
// JWT_HMAC_SECRET=xyz node scripts/mint-search-token.mjs
|
||||
|
||||
import { createHmac } from "node:crypto"
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { dirname, resolve } from "node:path"
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = resolve(HERE, "..")
|
||||
const ENV_LOCAL = resolve(PROJECT_ROOT, ".env.local")
|
||||
|
||||
function arg(name, fallback) {
|
||||
const prefix = `--${name}=`
|
||||
const hit = process.argv.find((a) => a.startsWith(prefix))
|
||||
return hit ? hit.slice(prefix.length) : fallback
|
||||
}
|
||||
|
||||
const flag = (name) => process.argv.includes(`--${name}`)
|
||||
|
||||
const SECRET = process.env.JWT_HMAC_SECRET ?? "test-secret-change-me"
|
||||
const TENANT = arg("tenant", process.env.VITE_ARCADIA_TENANT ?? "platform-admin")
|
||||
const SUBJECT = arg("sub", "arcadia-admin")
|
||||
const ALGORITHM = (arg("alg", "HS512")).toUpperCase()
|
||||
const DAYS = Number(arg("days", "365"))
|
||||
const PRINT_ONLY = flag("print")
|
||||
|
||||
const HMAC_ALG = { HS256: "sha256", HS384: "sha384", HS512: "sha512" }[ALGORITHM]
|
||||
if (!HMAC_ALG) {
|
||||
console.error(`Unsupported algorithm "${ALGORITHM}". Use HS256, HS384, or HS512.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const b64 = (v) =>
|
||||
Buffer.from(typeof v === "string" ? v : JSON.stringify(v)).toString("base64url")
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const header = b64({ alg: ALGORITHM, typ: "JWT" })
|
||||
const payload = b64({
|
||||
sub: SUBJECT,
|
||||
tenant_id: TENANT,
|
||||
iat: now,
|
||||
exp: now + DAYS * 86400,
|
||||
})
|
||||
const signature = createHmac(HMAC_ALG, SECRET)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url")
|
||||
const token = `${header}.${payload}.${signature}`
|
||||
|
||||
if (PRINT_ONLY) {
|
||||
console.log(token)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Upsert VITE_ARCADIA_SEARCH_TOKEN in .env.local without disturbing other keys.
|
||||
const KEY = "VITE_ARCADIA_SEARCH_TOKEN"
|
||||
let existing = ""
|
||||
if (existsSync(ENV_LOCAL)) existing = readFileSync(ENV_LOCAL, "utf8")
|
||||
const line = `${KEY}=${token}`
|
||||
const next = new RegExp(`^${KEY}=.*$`, "m").test(existing)
|
||||
? existing.replace(new RegExp(`^${KEY}=.*$`, "m"), line)
|
||||
: (existing.endsWith("\n") || existing === "" ? existing : existing + "\n") + line + "\n"
|
||||
writeFileSync(ENV_LOCAL, next)
|
||||
|
||||
console.log(`Wrote ${KEY} to ${ENV_LOCAL}`)
|
||||
console.log(` alg: ${ALGORITHM}`)
|
||||
console.log(` tenant: ${TENANT}`)
|
||||
console.log(` sub: ${SUBJECT}`)
|
||||
console.log(` expires: ${new Date((now + DAYS * 86400) * 1000).toISOString()}`)
|
||||
console.log(` secret: ${SECRET === "test-secret-change-me" ? "(default — override with JWT_HMAC_SECRET=)" : "(from JWT_HMAC_SECRET)"}`)
|
||||
console.log(``)
|
||||
console.log(`Restart 'npm run dev' so Vite picks up the new env var.`)
|
||||
Reference in New Issue
Block a user