Files
arcadia-admin/app/routes/profile.tsx
jules c2730e3c77 profile: real avatar upload + storage form fix
- Add a digital-objects client (uploadFile: open session → PUT to
  presigned URL → complete) and a profile client (getProfile,
  updateProfile, pickAvatarUrl variant resolver).
- Wire profile.tsx avatar upload to use the real flow: validate
  image+size, upload to digital_objects tagged "avatar", PATCH
  /api/v1/profile with avatar_digital_object_id, mirror the resolved
  URL into local prefs so the existing <AvatarImage> binding keeps
  working. Show Uploading… state and an inline error banner. Clear
  detaches via avatar_digital_object_id: null.
- Fix the storage form sending the wrong field name for the local
  backend — arcadia's StorageConfig changeset requires `base_path`,
  not `path`. The 422 was silent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:02:52 +10:00

568 lines
19 KiB
TypeScript

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,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
import { useAgents } from "~/lib/agents"
import { pageTitle } from "~/lib/page-meta"
import {
profileInitials,
resetProfile,
saveProfile,
useProfile,
type Profile,
} from "~/lib/profile"
import { getUser, updateUser, type User } from "~/lib/arcadia/users"
import { uploadFile } from "~/lib/arcadia/digital-objects"
import {
getProfile,
updateProfile as updateArcadiaProfile,
pickAvatarUrl,
type Profile as ArcadiaProfile,
} from "~/lib/arcadia/profiles"
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()
// 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)
// Server-side profile (avatar lives here — `prefs.avatarUrl` mirrors
// the resolved URL so the existing <AvatarImage> bindings keep working).
const [arcadiaProfile, setArcadiaProfile] = useState<ArcadiaProfile | null>(null)
const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarError, setAvatarError] = useState<string | null>(null)
const loadAccount = useCallback(async () => {
if (!session) return
setAccountLoading(true)
setAccountError(null)
try {
const [u, p] = await Promise.all([
getUser(arcadia, session.userId),
getProfile(arcadia).catch(() => null),
])
setAccount(u)
setAccountDraft({
first_name: u.first_name ?? "",
last_name: u.last_name ?? "",
email: u.email,
})
if (p) {
setArcadiaProfile(p)
const url = pickAvatarUrl(p)
if (url) setPrefs((d) => ({ ...d, avatarUrl: url }))
}
} catch (err) {
setAccountError(
err instanceof ArcadiaError ? err.message : "Failed to load account.",
)
} finally {
setAccountLoading(false)
}
}, [arcadia, session])
useEffect(() => {
loadAccount()
}, [loadAccount])
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 = async (file: File | null) => {
setAvatarError(null)
if (!file) {
// Clear: detach the digital object on the server, then drop the
// local cache. Keep the local cache cleared even if the server call
// fails so the UI reflects the user's intent.
setPrefs((d) => ({ ...d, avatarUrl: "" }))
try {
const updated = await updateArcadiaProfile(arcadia, {
avatar_digital_object_id: null,
})
setArcadiaProfile(updated)
savePrefsLocal({ ...prefs, avatarUrl: "" })
} catch (err) {
setAvatarError(
err instanceof Error ? err.message : "Failed to clear avatar.",
)
}
return
}
if (!file.type.startsWith("image/")) {
setAvatarError("Avatar must be an image (PNG, JPG, GIF, WebP).")
return
}
// 8MB hard cap client-side; arcadia will enforce its own quota too.
if (file.size > 8 * 1024 * 1024) {
setAvatarError("Avatar is too large (max 8MB).")
return
}
setAvatarUploading(true)
try {
const obj = await uploadFile(arcadia, file, { tags: ["avatar"] })
const updated = await updateArcadiaProfile(arcadia, {
avatar_digital_object_id: obj.id,
})
setArcadiaProfile(updated)
const url = pickAvatarUrl(updated)
if (url) {
const next = { ...prefs, avatarUrl: url }
setPrefs(next)
savePrefsLocal(next)
}
} catch (err) {
setAvatarError(
err instanceof Error ? err.message : "Avatar upload failed.",
)
} finally {
setAvatarUploading(false)
}
}
// Save the local-prefs mirror (separate from `savePrefs`, which only
// runs on the explicit "Save" button — avatar changes auto-persist
// because the server already accepted them).
const savePrefsLocal = (next: Profile) => {
saveProfile(next)
}
const savePrefs = () => {
saveProfile(prefs)
setPrefsSavedAt(Date.now())
}
const defaultAgent = agents.find((a) => a.id === prefs.defaultAgentId) ?? null
return (
<AppShell>
<Card>
<CardHeader>
<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>
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">
{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-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>
Avatar uploads land in your tenant's storage backend; the rest
(bio, signature, default persona) stays local to this browser.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Avatar</span>
{avatarError ? (
<AlertBanner variant="error">{avatarError}</AlertBanner>
) : null}
<div className="flex items-center gap-3">
<label
aria-disabled={avatarUploading}
className={[
"inline-flex w-fit items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm",
avatarUploading
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-accent hover:text-accent-foreground",
].join(" ")}
>
<input
data-action="profile-avatar-upload"
type="file"
accept="image/*"
className="sr-only"
disabled={avatarUploading}
onChange={(e) => onPickAvatar(e.target.files?.[0] ?? null)}
/>
{avatarUploading ? "Uploading…" : "Upload avatar"}
</label>
{prefs.avatarUrl && !avatarUploading && (
<Button
data-action="profile-avatar-remove"
variant="ghost"
size="sm"
onClick={() => onPickAvatar(null)}
className="text-muted-foreground"
>
<Trash2 className="size-3.5" /> Remove
</Button>
)}
</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="Title" hint="Your role at work.">
<Input
data-action="profile-title"
value={prefs.title}
onChange={(e) =>
setPrefs((d) => ({ ...d, title: e.target.value }))
}
placeholder="e.g. Platform admin"
/>
</Field>
<Field
label="Default agent"
hint="Used as the active persona on first load."
>
<DropdownMenu>
<DropdownMenuTrigger
data-action="profile-default-agent"
className="inline-flex h-9 items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="truncate">
{defaultAgent ? (
<>
<span className="font-medium">{defaultAgent.name}</span>
<span className="text-muted-foreground">
{" "}
— {defaultAgent.role}
</span>
</>
) : (
"Use first available"
)}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
onClick={() =>
setPrefs((d) => ({ ...d, defaultAgentId: "" }))
}
data-state={!prefs.defaultAgentId ? "checked" : undefined}
>
First available
</DropdownMenuItem>
{agents.map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() =>
setPrefs((d) => ({ ...d, defaultAgentId: a.id }))
}
data-state={
prefs.defaultAgentId === a.id ? "checked" : undefined
}
className="flex flex-col items-start"
>
<span className="font-medium">{a.name}</span>
<span className="text-xs text-muted-foreground">
{a.role}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</Field>
</div>
<Field
label="Bio"
hint="A short blurb the assistant can reference (e.g. 'I work mostly in TypeScript')."
>
<Textarea
data-action="profile-bio"
value={prefs.bio}
onChange={(e) => setPrefs((d) => ({ ...d, bio: e.target.value }))}
rows={3}
placeholder="Tell the assistant about you."
/>
</Field>
<Field
label="Signature"
hint="Appended automatically when you ask the assistant to draft an email or note."
>
<Textarea
data-action="profile-signature"
value={prefs.signature}
onChange={(e) =>
setPrefs((d) => ({ ...d, signature: e.target.value }))
}
rows={3}
placeholder={`Cheers,\n${account?.full_name || "Your name"}`}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="profile-prefs-save"
onClick={savePrefs}
disabled={!prefsDirty}
>
Save preferences
</Button>
<Button
data-action="profile-prefs-revert"
variant="ghost"
onClick={() => setPrefs(profile)}
disabled={!prefsDirty}
>
Revert
</Button>
<Button
data-action="profile-prefs-reset"
variant="ghost"
onClick={() => {
resetProfile()
setPrefsSavedAt(Date.now())
}}
>
Reset to defaults
</Button>
{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>
)}
</div>
</CardContent>
</Card>
</AppShell>
)
}
function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
{children}
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</label>
)
}