Files
arcadia-admin/app/routes/profile.tsx
jules ffe3fc0473 profile: drop unused title/signature/defaultAgentId fields
These were aspirational placeholders — stored to localStorage but never
read by anything. Removed from the form, types, and persistence layer.
Local profile is now just the avatar URL mirror, which the appbar reads
before the server profile fetch resolves on mount.

Preferences card renamed to "Avatar" since that's all that's left.
Re-add server-backed if/when something actually consumes them.

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

569 lines
18 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 { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
import { pageTitle } from "~/lib/page-meta"
import {
profileInitials,
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()
// Mirror of the resolved avatar URL — kept in localStorage so the
// <AvatarImage> in the appbar can render before the profile fetch
// resolves on next mount.
const [prefs, setPrefs] = useState<Profile>(profile)
useEffect(() => {
setPrefs(profile)
}, [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)
// Public-profile editable fields, server-backed via PATCH /api/v1/profile.
const [profileDraft, setProfileDraft] = useState<{
bio: string
phone: string
location: string
timezone: string
}>({ bio: "", phone: "", location: "", timezone: "" })
const [profileSaving, setProfileSaving] = useState(false)
const [profileSavedAt, setProfileSavedAt] = useState<number | null>(null)
const [profileError, setProfileError] = useState<string | null>(null)
const profileDirty =
!!arcadiaProfile &&
(profileDraft.bio !== (arcadiaProfile.bio ?? "") ||
profileDraft.phone !== (arcadiaProfile.phone ?? "") ||
profileDraft.location !== (arcadiaProfile.location ?? "") ||
profileDraft.timezone !== (arcadiaProfile.timezone ?? ""))
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)
setProfileDraft({
bio: p.bio ?? "",
phone: p.phone ?? "",
location: p.location ?? "",
timezone: p.timezone ?? "",
})
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)
}
}
const saveArcadiaProfile = async () => {
setProfileSaving(true)
setProfileError(null)
try {
const updated = await updateArcadiaProfile(arcadia, {
bio: profileDraft.bio || null,
phone: profileDraft.phone || null,
location: profileDraft.location || null,
timezone: profileDraft.timezone || null,
})
setArcadiaProfile(updated)
setProfileDraft({
bio: updated.bio ?? "",
phone: updated.phone ?? "",
location: updated.location ?? "",
timezone: updated.timezone ?? "",
})
setProfileSavedAt(Date.now())
} catch (err) {
setProfileError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed.",
)
} finally {
setProfileSaving(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)
}
}
// Mirror the avatar URL into localStorage so it survives reloads.
const savePrefsLocal = (next: Profile) => {
saveProfile(next)
}
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>Profile</CardTitle>
<CardDescription>
Public profile fields stored on arcadia. Visible to other members
of this tenant.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{profileError ? (
<AlertBanner variant="error">{profileError}</AlertBanner>
) : null}
<Field
label="Bio"
hint="A short blurb about you."
>
<Textarea
data-action="profile-bio"
value={profileDraft.bio}
onChange={(e) =>
setProfileDraft((d) => ({ ...d, bio: e.target.value }))
}
rows={3}
placeholder="Tell others what you work on."
/>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Phone">
<Input
data-action="profile-phone"
value={profileDraft.phone}
onChange={(e) =>
setProfileDraft((d) => ({ ...d, phone: e.target.value }))
}
placeholder="+61 …"
/>
</Field>
<Field label="Location">
<Input
data-action="profile-location"
value={profileDraft.location}
onChange={(e) =>
setProfileDraft((d) => ({ ...d, location: e.target.value }))
}
placeholder="Melbourne, AU"
/>
</Field>
<Field
label="Timezone"
hint="IANA name (e.g. Australia/Melbourne)."
>
<Input
data-action="profile-timezone"
value={profileDraft.timezone}
onChange={(e) =>
setProfileDraft((d) => ({ ...d, timezone: e.target.value }))
}
placeholder="Australia/Melbourne"
/>
</Field>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="profile-arcadia-save"
onClick={saveArcadiaProfile}
disabled={!profileDirty || profileSaving}
>
{profileSaving ? "Saving…" : "Save profile"}
</Button>
<Button
data-action="profile-arcadia-revert"
variant="ghost"
onClick={() => {
if (!arcadiaProfile) return
setProfileDraft({
bio: arcadiaProfile.bio ?? "",
phone: arcadiaProfile.phone ?? "",
location: arcadiaProfile.location ?? "",
timezone: arcadiaProfile.timezone ?? "",
})
}}
disabled={!profileDirty || profileSaving}
>
Revert
</Button>
{profileSavedAt && !profileDirty ? (
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
<Check className="size-4" /> Saved.
</span>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Avatar</CardTitle>
<CardDescription>
Uploads land in your tenant's storage backend.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{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, GIF, or WebP. Max 8MB.
</span>
</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>
)
}