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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user