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>
This commit is contained in:
88
app/lib/arcadia/digital-objects.ts
Normal file
88
app/lib/arcadia/digital-objects.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// Arcadia digital objects API — minimal client covering the upload flow
|
||||||
|
// used by the avatar uploader. The full digital-objects API is much
|
||||||
|
// larger; add endpoints here as we wire more features.
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||||
|
|
||||||
|
export interface DigitalObject {
|
||||||
|
id: string
|
||||||
|
tenant_id?: string
|
||||||
|
user_id?: string
|
||||||
|
storage_config_id?: string
|
||||||
|
filename?: string
|
||||||
|
original_filename?: string
|
||||||
|
content_type?: string
|
||||||
|
size_bytes?: number
|
||||||
|
key?: string
|
||||||
|
object_key?: string
|
||||||
|
status?: "active" | "archived" | "deleted"
|
||||||
|
inserted_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadSession {
|
||||||
|
id: string
|
||||||
|
upload_url: string
|
||||||
|
presigned_url?: string
|
||||||
|
expires_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateUploadSessionInput {
|
||||||
|
filename: string
|
||||||
|
content_type: string
|
||||||
|
size_bytes: number
|
||||||
|
storage_config_id?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-step upload: open a session, PUT the bytes to the presigned URL
|
||||||
|
* the session returns, complete the session to land the digital_object.
|
||||||
|
*
|
||||||
|
* Returns the finalized DigitalObject record.
|
||||||
|
*/
|
||||||
|
export async function uploadFile(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
file: File,
|
||||||
|
opts: { storage_config_id?: string; tags?: string[] } = {},
|
||||||
|
): Promise<DigitalObject> {
|
||||||
|
const sessionInput: CreateUploadSessionInput = {
|
||||||
|
filename: file.name,
|
||||||
|
content_type: file.type || "application/octet-stream",
|
||||||
|
size_bytes: file.size,
|
||||||
|
storage_config_id: opts.storage_config_id,
|
||||||
|
tags: opts.tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await arcadia.POST<{ data: UploadSession }>(
|
||||||
|
"/api/v1/digital_objects/upload_sessions",
|
||||||
|
{ body: { upload_session: sessionInput } },
|
||||||
|
)
|
||||||
|
|
||||||
|
const uploadUrl = session.data.upload_url || session.data.presigned_url
|
||||||
|
if (!uploadUrl) throw new Error("Upload session returned no upload URL")
|
||||||
|
|
||||||
|
const putRes = await fetch(uploadUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": sessionInput.content_type },
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
if (!putRes.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Upload to storage failed: ${putRes.status} ${await putRes.text().catch(() => "")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = await arcadia.POST<{ data: DigitalObject }>(
|
||||||
|
`/api/v1/digital_objects/upload_sessions/${encodeURIComponent(session.data.id)}/complete`,
|
||||||
|
{ body: {} },
|
||||||
|
)
|
||||||
|
return completed.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDigitalObject(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await arcadia.DELETE(`/api/v1/digital_objects/${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
73
app/lib/arcadia/profiles.ts
Normal file
73
app/lib/arcadia/profiles.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Arcadia profile API. Backed by /api/v1/profile (current user) — handles
|
||||||
|
// avatar wiring (avatar_digital_object_id + variant URLs) and the basic
|
||||||
|
// profile fields. The "profile" here is the per-tenant profile row, not
|
||||||
|
// the auth account.
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
tenant_id: string
|
||||||
|
avatar_url: string | null
|
||||||
|
avatar_digital_object_id: string | null
|
||||||
|
/**
|
||||||
|
* Variant URLs keyed by size (e.g. "thumbnail", "medium", "original").
|
||||||
|
* Shape depends on the storage backend; treat as best-effort.
|
||||||
|
*/
|
||||||
|
avatar_urls?: Record<string, string> | null
|
||||||
|
bio?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
location?: string | null
|
||||||
|
timezone?: string | null
|
||||||
|
inserted_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileUpdateInput {
|
||||||
|
avatar_digital_object_id?: string | null
|
||||||
|
bio?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
location?: string | null
|
||||||
|
timezone?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile(arcadia: ArcadiaClient): Promise<Profile> {
|
||||||
|
const res = await arcadia.GET<{ data: Profile } | Profile>("/api/v1/profile")
|
||||||
|
return "data" in (res as object)
|
||||||
|
? (res as { data: Profile }).data
|
||||||
|
: (res as Profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: ProfileUpdateInput,
|
||||||
|
): Promise<Profile> {
|
||||||
|
const res = await arcadia.PATCH<{ data: Profile } | Profile>("/api/v1/profile", {
|
||||||
|
body: { profile: input },
|
||||||
|
})
|
||||||
|
return "data" in (res as object)
|
||||||
|
? (res as { data: Profile }).data
|
||||||
|
: (res as Profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the most appropriate avatar URL from a profile. Prefers a small
|
||||||
|
* variant (thumbnail / small / medium) if available; falls back to
|
||||||
|
* `avatar_url`, then null.
|
||||||
|
*/
|
||||||
|
export function pickAvatarUrl(profile: Profile | null | undefined): string | null {
|
||||||
|
if (!profile) return null
|
||||||
|
const variants = profile.avatar_urls
|
||||||
|
if (variants && typeof variants === "object") {
|
||||||
|
return (
|
||||||
|
variants.thumbnail ||
|
||||||
|
variants.small ||
|
||||||
|
variants.medium ||
|
||||||
|
variants.original ||
|
||||||
|
profile.avatar_url ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return profile.avatar_url ?? null
|
||||||
|
}
|
||||||
@@ -149,7 +149,9 @@ export const SECRET_FIELDS: Record<StorageBackend, readonly string[]> = {
|
|||||||
export const REQUIRED_FIELDS: Record<StorageBackend, readonly string[]> = {
|
export const REQUIRED_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||||
s3: ["bucket", "region", "access_key_id", "secret_access_key"],
|
s3: ["bucket", "region", "access_key_id", "secret_access_key"],
|
||||||
gcs: ["bucket", "service_account_json"],
|
gcs: ["bucket", "service_account_json"],
|
||||||
local: ["path"],
|
// Local backend's filesystem root. Backend changeset rejects "path" — must
|
||||||
|
// be `base_path`. Keep this in sync with `Arcadia.Storage.Adapters.Local`.
|
||||||
|
local: ["base_path"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OPTIONAL_FIELDS: Record<StorageBackend, readonly string[]> = {
|
export const OPTIONAL_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ import {
|
|||||||
type Profile,
|
type Profile,
|
||||||
} from "~/lib/profile"
|
} from "~/lib/profile"
|
||||||
import { getUser, updateUser, type User } from "~/lib/arcadia/users"
|
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"
|
import { updateSessionUser, useSession } from "~/lib/session"
|
||||||
|
|
||||||
export const meta = () => pageTitle("Profile")
|
export const meta = () => pageTitle("Profile")
|
||||||
@@ -69,18 +76,32 @@ export default function ProfileRoute() {
|
|||||||
const [accountSavedAt, setAccountSavedAt] = useState<number | null>(null)
|
const [accountSavedAt, setAccountSavedAt] = useState<number | null>(null)
|
||||||
const [accountError, setAccountError] = useState<string | 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 () => {
|
const loadAccount = useCallback(async () => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
setAccountLoading(true)
|
setAccountLoading(true)
|
||||||
setAccountError(null)
|
setAccountError(null)
|
||||||
try {
|
try {
|
||||||
const u = await getUser(arcadia, session.userId)
|
const [u, p] = await Promise.all([
|
||||||
|
getUser(arcadia, session.userId),
|
||||||
|
getProfile(arcadia).catch(() => null),
|
||||||
|
])
|
||||||
setAccount(u)
|
setAccount(u)
|
||||||
setAccountDraft({
|
setAccountDraft({
|
||||||
first_name: u.first_name ?? "",
|
first_name: u.first_name ?? "",
|
||||||
last_name: u.last_name ?? "",
|
last_name: u.last_name ?? "",
|
||||||
email: u.email,
|
email: u.email,
|
||||||
})
|
})
|
||||||
|
if (p) {
|
||||||
|
setArcadiaProfile(p)
|
||||||
|
const url = pickAvatarUrl(p)
|
||||||
|
if (url) setPrefs((d) => ({ ...d, avatarUrl: url }))
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAccountError(
|
setAccountError(
|
||||||
err instanceof ArcadiaError ? err.message : "Failed to load account.",
|
err instanceof ArcadiaError ? err.message : "Failed to load account.",
|
||||||
@@ -130,18 +151,65 @@ export default function ProfileRoute() {
|
|||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPickAvatar = (file: File | null) => {
|
const onPickAvatar = async (file: File | null) => {
|
||||||
|
setAvatarError(null)
|
||||||
|
|
||||||
if (!file) {
|
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: "" }))
|
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
|
return
|
||||||
}
|
}
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
if (!file.type.startsWith("image/")) {
|
||||||
const result = reader.result
|
setAvatarError("Avatar must be an image (PNG, JPG, GIF, WebP).")
|
||||||
if (typeof result === "string")
|
return
|
||||||
setPrefs((d) => ({ ...d, avatarUrl: result }))
|
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
// 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 = () => {
|
const savePrefs = () => {
|
||||||
@@ -302,25 +370,37 @@ export default function ProfileRoute() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Preferences</CardTitle>
|
<CardTitle>Preferences</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Local-only settings stored in this browser — avatar, bio, signature,
|
Avatar uploads land in your tenant's storage backend; the rest
|
||||||
and the assistant's default persona.
|
(bio, signature, default persona) stays local to this browser.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-6">
|
<CardContent className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium">Avatar</span>
|
<span className="text-sm font-medium">Avatar</span>
|
||||||
|
{avatarError ? (
|
||||||
|
<AlertBanner variant="error">{avatarError}</AlertBanner>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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
|
<input
|
||||||
data-action="profile-avatar-upload"
|
data-action="profile-avatar-upload"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
|
disabled={avatarUploading}
|
||||||
onChange={(e) => onPickAvatar(e.target.files?.[0] ?? null)}
|
onChange={(e) => onPickAvatar(e.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
Upload avatar
|
{avatarUploading ? "Uploading…" : "Upload avatar"}
|
||||||
</label>
|
</label>
|
||||||
{prefs.avatarUrl && (
|
{prefs.avatarUrl && !avatarUploading && (
|
||||||
<Button
|
<Button
|
||||||
data-action="profile-avatar-remove"
|
data-action="profile-avatar-remove"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
Reference in New Issue
Block a user