import { useState, useCallback, useMemo, useRef } from "react"; import { Link } from "@tanstack/react-router"; import { ArrowLeft, Save, Loader2, Camera, X, User as UserIcon } from "lucide-react"; import { Button } from "@/client/components/ui/button"; import { useAuthStore } from "@/client/stores/auth-store"; import { useMemberWorkspaces } from "@/client/stores/workspace-directory"; import { useWorkspaceReplica } from "@/client/stores/workspace-replica"; import { useLastVisitedWorkspaceId } from "@/client/stores/workspace-navigation"; import { api, toApiError } from "@/client/lib/api"; import { SESSION_MODES } from "@/client/lib/constants"; import { useDocumentTitle } from "@/client/hooks/use-document-title"; import { Skeleton } from "@/client/components/ui/skeleton"; import { isWorkspaceWriterRole } from "@/shared/entitlements"; function ProfileSettingsSkeleton() { return (
); } export function ProfileSettings() { useDocumentTitle("Profile"); const user = useAuthStore((s) => s.user); const sessionMode = useAuthStore((s) => s.sessionMode); const refreshState = useAuthStore((s) => s.refreshState); const lastWsId = useLastVisitedWorkspaceId(); const lastWsReplica = useWorkspaceReplica(lastWsId); const memberWorkspaces = useMemberWorkspaces(); // `currentWorkspace` drives the Back link (keep pointing at the last-visited // workspace — shared or member). Avatar upload target is derived separately. const currentWorkspace = lastWsReplica?.workspace ?? null; // Avatar presign borrows workspace-level upload authorization, which only // succeeds for writer roles. Prefer the last-visited workspace when it is // writable, else fall back to any writable workspace on the account. When // none exists, the upload affordance is disabled with explanatory copy. const avatarUploadWorkspace = useMemo(() => { const preferred = lastWsId ? memberWorkspaces.find((w) => w.id === lastWsId) : undefined; if (preferred && isWorkspaceWriterRole(preferred.role)) return preferred; return memberWorkspaces.find((w) => isWorkspaceWriterRole(w.role)) ?? null; }, [lastWsId, memberWorkspaces]); const [name, setName] = useState(user?.name ?? ""); const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? ""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const canMutateProfile = sessionMode === SESSION_MODES.AUTHENTICATED && refreshState !== "refreshing"; const canUploadAvatar = !!avatarUploadWorkspace && canMutateProfile; const hasChanges = name.trim() !== (user?.name ?? "") || (avatarUrl.trim() || null) !== (user?.avatar_url ?? null); const handleAvatarUpload = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !avatarUploadWorkspace || !canMutateProfile) return; if (!file.type.startsWith("image/")) { setError("Please select an image file"); return; } if (file.size > 2 * 1024 * 1024) { setError("Image must be under 2MB"); return; } setUploading(true); setError(null); try { const presign = await api.uploads.presign(avatarUploadWorkspace.id, { filename: file.name, content_type: file.type as "image/jpeg" | "image/png" | "image/gif" | "image/webp", size_bytes: file.size, }); await api.uploads.uploadData(presign.upload_url, file); setAvatarUrl(presign.url); } catch (err) { setError(toApiError(err).message); } finally { setUploading(false); } }, [avatarUploadWorkspace, canMutateProfile], ); const handleSave = useCallback(async () => { if (saving || !name.trim() || !canMutateProfile) return; setSaving(true); setError(null); setSuccess(false); try { const updated = await api.profile.update({ name: name.trim(), avatar_url: avatarUrl.trim() || null, }); useAuthStore.getState().setAuth(useAuthStore.getState().accessToken!, updated); setSuccess(true); setTimeout(() => setSuccess(false), 2000); } catch (err) { setError(toApiError(err).message); } finally { setSaving(false); } }, [name, avatarUrl, saving, canMutateProfile]); if (!user) return ; const backSlug = currentWorkspace?.slug; return (
{backSlug ? ( Back ) : ( Back )}

Profile

{avatarUrl ? ( {user.name} ) : ( )}

Profile photo

JPG, PNG, GIF, or WebP. Max 2MB.

{!avatarUploadWorkspace && (

Avatar upload requires a workspace you can write to.

)} {avatarUrl && ( )}
setName(e.target.value)} className="w-full rounded-lg border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-500 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30" placeholder="Your name" />

{user.email}

{error &&

{error}

} {refreshState === "refreshing" && (

Restoring your session before profile changes.

)} {refreshState !== "refreshing" && sessionMode !== SESSION_MODES.AUTHENTICATED && (

Sign in again to edit your profile.

)}
{success && Saved}
); }