"use client" import { useEffect, useRef, useState } from "react" import { User as UserIcon, Upload, Trash2, Loader2, Check, AlertCircle, Shield, Lock, X, Settings2, CheckCircle2, } from "lucide-react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config" interface ProfileData { success: boolean username?: string | null display_name?: string | null has_avatar?: boolean avatar_mtime?: number | null avatar_content_type?: string | null message?: string } interface ProfileProps { /** Optional navigation hook so the page can link to Security for * password / 2FA changes without redirecting through a URL. */ onOpenSecurity?: () => void } /** * Profile page (Fase 2, v1.2.2). * * Lets the operator edit their **display name** and upload / remove * their **avatar**. Username is read-only (changing it requires * disabling and reconfiguring auth from Security). Password / 2FA * are intentionally not editable from this page — those live in * Security to keep the "account security" surface in one place. * * Layout: centered, two cards (Profile + Account security shortcut). * Display name uses the same Edit / Save / Cancel pattern as the * Health Thresholds / Notifications panels — read-only by default, * the operator hits Edit to start typing. */ export function Profile({ onOpenSecurity }: ProfileProps) { const [profile, setProfile] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) // Display name: read-only by default, editable after pressing Edit. // Mirrors the editMode pattern used in HealthThresholds / Notifications // so the operator never types into a field that isn't ready to be saved. const [displayEditMode, setDisplayEditMode] = useState(false) const [displayDraft, setDisplayDraft] = useState("") const [savingDisplay, setSavingDisplay] = useState(false) const [savedDisplay, setSavedDisplay] = useState(false) // Avatar state. const [uploadingAvatar, setUploadingAvatar] = useState(false) const [avatarError, setAvatarError] = useState(null) const [avatarBlobUrl, setAvatarBlobUrl] = useState(null) const fileInputRef = useRef(null) const loadProfile = async () => { try { const data = await fetchApi("/api/auth/profile") setProfile(data) setDisplayDraft(data.display_name || "") } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } } useEffect(() => { loadProfile() }, []) // Avatar fetch. Same blob-URL pattern as in AvatarMenu — the endpoint // requires the Bearer header, which can't send. Plain // `` would render a broken image icon (the bug the user reported). useEffect(() => { let cancelled = false let currentBlobUrl: string | null = null if (profile?.has_avatar) { const token = getAuthToken() const url = `${getApiUrl("/api/auth/profile/avatar")}?v=${profile.avatar_mtime || ""}` fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} }) .then(r => (r.ok ? r.blob() : null)) .then(blob => { if (cancelled || !blob) return currentBlobUrl = URL.createObjectURL(blob) setAvatarBlobUrl(currentBlobUrl) }) .catch(() => { if (!cancelled) setAvatarBlobUrl(null) }) } else { setAvatarBlobUrl(null) } return () => { cancelled = true if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl) } }, [profile?.has_avatar, profile?.avatar_mtime]) const initial = (profile?.display_name || profile?.username || "U") .trim() .charAt(0) .toUpperCase() const hasDisplayChanges = displayDraft !== (profile?.display_name || "") const handleEditDisplay = () => { setDisplayEditMode(true) setSavedDisplay(false) setError(null) } const handleCancelDisplay = () => { setDisplayDraft(profile?.display_name || "") setDisplayEditMode(false) setError(null) } const handleSaveDisplayName = async () => { if (!hasDisplayChanges) { setDisplayEditMode(false) return } setSavingDisplay(true) setError(null) setSavedDisplay(false) try { const data = await fetchApi("/api/auth/profile", { method: "PUT", body: JSON.stringify({ display_name: displayDraft }), }) if (!data.success) { setError(data.message || "Failed to save display name") return } setProfile(data) setDisplayEditMode(false) setSavedDisplay(true) setTimeout(() => setSavedDisplay(false), 2500) if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("proxmenux:profile-changed")) } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSavingDisplay(false) } } const handleAvatarPick = () => fileInputRef.current?.click() const handleAvatarFile = async (file: File) => { setUploadingAvatar(true) setAvatarError(null) try { const token = getAuthToken() const headers: Record = {} if (token) headers["Authorization"] = `Bearer ${token}` // Raw upload (Content-Type = the image's own MIME) — simpler than // multipart and the backend handles both. headers["Content-Type"] = file.type const r = await fetch(getApiUrl("/api/auth/profile/avatar"), { method: "POST", headers, body: file, }) const data: ProfileData = await r.json().catch(() => ({ success: false })) if (!r.ok || !data.success) { setAvatarError(data.message || `Upload failed (${r.status})`) return } setProfile(data) if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("proxmenux:profile-changed")) } } catch (e) { setAvatarError(e instanceof Error ? e.message : String(e)) } finally { setUploadingAvatar(false) // Reset the input so picking the same file twice in a row still // fires the change event. if (fileInputRef.current) fileInputRef.current.value = "" } } const handleAvatarDelete = async () => { setUploadingAvatar(true) setAvatarError(null) try { const token = getAuthToken() const headers: Record = {} if (token) headers["Authorization"] = `Bearer ${token}` const r = await fetch(getApiUrl("/api/auth/profile/avatar"), { method: "DELETE", headers, }) const data: ProfileData = await r.json().catch(() => ({ success: false })) if (!r.ok || !data.success) { setAvatarError(data.message || `Delete failed (${r.status})`) return } setProfile(data) if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("proxmenux:profile-changed")) } } catch (e) { setAvatarError(e instanceof Error ? e.message : String(e)) } finally { setUploadingAvatar(false) } } if (loading) { return (
Loading profile…
) } if (error && !profile) { return (
Failed to load profile
{error}
) } return (
{/* Edit / Save / Cancel sit in the card header — same pattern as Health Thresholds and Notifications. Avatar actions (upload / remove) stay independent of editMode because they're explicit one-shot actions, not field edits. */}
User Profile
{savedDisplay && ( Saved )} {displayEditMode ? ( <> ) : ( )}
Personal details rendered in the header avatar menu. None of this is required — the username already covers identity. Display name and avatar are decorative.
{/* ─── Avatar section ────────────────────────────────────── Big preview (160×160) so the operator can see the actual image they uploaded. `object-cover` keeps the aspect ratio and crops to fit the circle. */}
{avatarBlobUrl ? ( // eslint-disable-next-line @next/next/no-img-element ) : ( {initial} )} {uploadingAvatar && (
)}
{ const file = e.target.files?.[0] if (file) handleAvatarFile(file) }} /> {profile?.has_avatar && ( )}

PNG, JPEG, WebP or GIF. Up to 2 MB. The image isn't resized — render it square or pre-crop for best results in the header.

{avatarError && (
{avatarError}
)}
{/* ─── Username (read-only) ─── */}

The login name. To change it, disable authentication and reconfigure from Security.

{/* ─── Display name (Edit controls live in the card header) ─── */}
setDisplayDraft(e.target.value)} placeholder={profile?.username || "Display name"} maxLength={64} disabled={!displayEditMode || savingDisplay} className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default" />

Shown above the username inside the avatar menu. Leave empty to show the username itself. Up to 64 characters.

{error && displayEditMode && (
{error}
)}
{/* ─── Account security shortcut ─── */}
Account security
Password, two-factor authentication and API tokens live in the Security panel.
{onOpenSecurity ? ( ) : (

Open the Security tab from the navigation.

)}
) }