mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
466 lines
17 KiB
TypeScript
466 lines
17 KiB
TypeScript
"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"
|
||
import { useT } from "../lib/i18n/provider"
|
||
|
||
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 t = useT()
|
||
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(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<string | null>(null)
|
||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const loadProfile = async () => {
|
||
try {
|
||
const data = await fetchApi<ProfileData>("/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 <img src=…> can't send. Plain
|
||
// `<img>` 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<ProfileData>("/api/auth/profile", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ display_name: displayDraft }),
|
||
})
|
||
if (!data.success) {
|
||
setError(data.message || t("profilePage.errors.saveDisplayNameFailed"))
|
||
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<string, string> = {}
|
||
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 || t("profilePage.errors.uploadFailed", { status: 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<string, string> = {}
|
||
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 || t("profilePage.errors.deleteFailed", { status: 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 (
|
||
<div className="max-w-2xl mx-auto">
|
||
<Card>
|
||
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
|
||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||
{t("profilePage.loading")}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error && !profile) {
|
||
return (
|
||
<div className="max-w-2xl mx-auto">
|
||
<Card>
|
||
<CardContent className="p-6">
|
||
<div className="flex items-start gap-2 text-red-500">
|
||
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
||
<div>
|
||
<div className="font-medium">{t("profilePage.loadFailed")}</div>
|
||
<div className="text-xs text-muted-foreground mt-1 break-all">{error}</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
{/* 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. */}
|
||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||
<div className="flex items-center gap-2">
|
||
<UserIcon className="h-5 w-5 text-cyan-500" />
|
||
<CardTitle>{t("profilePage.title")}</CardTitle>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{savedDisplay && (
|
||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||
<Check className="h-3.5 w-3.5" />
|
||
{t("status.saved")}
|
||
</span>
|
||
)}
|
||
{displayEditMode ? (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleCancelDisplay}
|
||
disabled={savingDisplay}
|
||
className="h-7 text-xs"
|
||
>
|
||
{t("actions.cancel")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={handleSaveDisplayName}
|
||
disabled={savingDisplay || !hasDisplayChanges}
|
||
className="h-7 text-xs bg-blue-600 hover:bg-blue-700"
|
||
>
|
||
{savingDisplay ? (
|
||
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||
) : (
|
||
<CheckCircle2 className="h-3 w-3 mr-1.5" />
|
||
)}
|
||
{t("actions.save")}
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleEditDisplay}
|
||
className="h-7 text-xs"
|
||
>
|
||
<Settings2 className="h-3 w-3 mr-1.5" />
|
||
{t("actions.edit")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<CardDescription>
|
||
{t("profilePage.description")}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
|
||
<CardContent className="space-y-8">
|
||
{/* ─── 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. */}
|
||
<div>
|
||
<Label className="text-sm">{t("profilePage.avatar.label")}</Label>
|
||
<div className="flex flex-col sm:flex-row items-start gap-6 mt-3">
|
||
<div className="relative shrink-0">
|
||
{avatarBlobUrl ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={avatarBlobUrl}
|
||
alt=""
|
||
className="w-40 h-40 rounded-full object-cover border border-border bg-cyan-500/5"
|
||
/>
|
||
) : (
|
||
<span className="w-40 h-40 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-6xl font-semibold border border-border">
|
||
{initial}
|
||
</span>
|
||
)}
|
||
{uploadingAvatar && (
|
||
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col gap-2 min-w-0">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) handleAvatarFile(file)
|
||
}}
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleAvatarPick}
|
||
disabled={uploadingAvatar}
|
||
className="justify-start"
|
||
>
|
||
<Upload className="h-3.5 w-3.5 mr-2" />
|
||
{profile?.has_avatar ? t("profilePage.avatar.replace") : t("profilePage.avatar.upload")}
|
||
</Button>
|
||
{profile?.has_avatar && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleAvatarDelete}
|
||
disabled={uploadingAvatar}
|
||
className="justify-start text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||
{t("profilePage.avatar.remove")}
|
||
</Button>
|
||
)}
|
||
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
|
||
{t("profilePage.avatar.hint")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{avatarError && (
|
||
<div className="mt-3 text-xs text-red-500 flex items-start gap-1.5">
|
||
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||
<span className="break-all">{avatarError}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ─── Username (read-only) ─── */}
|
||
<div>
|
||
<Label className="text-sm" htmlFor="profile-username">{t("profilePage.username.label")}</Label>
|
||
<Input
|
||
id="profile-username"
|
||
value={profile?.username || ""}
|
||
disabled
|
||
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground mt-1">
|
||
{t("profilePage.username.help")}
|
||
</p>
|
||
</div>
|
||
|
||
{/* ─── Display name (Edit controls live in the card header) ─── */}
|
||
<div>
|
||
<Label className="text-sm" htmlFor="profile-display">
|
||
{t("profilePage.displayName.label")} <span className="text-muted-foreground font-normal">{t("profilePage.displayName.optional")}</span>
|
||
</Label>
|
||
<Input
|
||
id="profile-display"
|
||
value={displayDraft}
|
||
onChange={(e) => setDisplayDraft(e.target.value)}
|
||
placeholder={profile?.username || t("profilePage.displayName.placeholder")}
|
||
maxLength={64}
|
||
disabled={!displayEditMode || savingDisplay}
|
||
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground mt-1">
|
||
{t("profilePage.displayName.help")}
|
||
</p>
|
||
{error && displayEditMode && (
|
||
<div className="mt-2 text-xs text-red-500 flex items-start gap-1.5">
|
||
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||
<span className="break-all">{error}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* ─── Account security shortcut ─── */}
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Shield className="h-5 w-5 text-orange-500" />
|
||
<CardTitle>{t("profilePage.accountSecurity.title")}</CardTitle>
|
||
</div>
|
||
<CardDescription>
|
||
{t("profilePage.accountSecurity.description")}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{onOpenSecurity ? (
|
||
<Button variant="outline" onClick={onOpenSecurity}>
|
||
<Lock className="h-4 w-4 mr-2" />
|
||
{t("profilePage.accountSecurity.openSecurity")}
|
||
</Button>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("profilePage.accountSecurity.fallback")}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|