diff --git a/AppImage/components/auth-setup.tsx b/AppImage/components/auth-setup.tsx index 86e01b29..46ffa05a 100644 --- a/AppImage/components/auth-setup.tsx +++ b/AppImage/components/auth-setup.tsx @@ -1,11 +1,11 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogTitle } from "./ui/dialog" import { Input } from "./ui/input" import { Label } from "./ui/label" -import { Shield, Lock, User, AlertCircle, Eye, EyeOff } from "lucide-react" +import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react" import { getApiUrl } from "../lib/api-config" interface AuthSetupProps { @@ -22,6 +22,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { const [loading, setLoading] = useState(false) const [showPassword, setShowPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) + // Profile (Fase 2 — v1.2.2). Both optional decorations on top of the + // mandatory username + password. Persisted via PUT /api/auth/profile + // and POST /api/auth/profile/avatar after the user lands a successful + // /api/auth/setup so we don't change the setup endpoint's contract. + const [displayName, setDisplayName] = useState("") + const [avatarFile, setAvatarFile] = useState(null) + const [avatarPreviewUrl, setAvatarPreviewUrl] = useState(null) + const fileInputRef = useRef(null) useEffect(() => { const checkOnboardingStatus = async () => { @@ -84,6 +92,18 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { } } + const handleAvatarPick = () => fileInputRef.current?.click() + + const handleAvatarChange = (file: File | null) => { + // Revoke the previous local preview so we don't leak blob URLs while + // the user picks another file before submitting. + if (avatarPreviewUrl) { + URL.revokeObjectURL(avatarPreviewUrl) + } + setAvatarFile(file) + setAvatarPreviewUrl(file ? URL.createObjectURL(file) : null) + } + const handleSetupAuth = async () => { setError("") @@ -125,6 +145,61 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { localStorage.removeItem("proxmenux-auth-declined") } + // Profile decorations (Fase 2). Sent as a follow-up to the setup + // call so the /api/auth/setup endpoint stays minimal (username + + // password only) — these calls reuse the existing profile + // endpoints and the JWT we just received. Failures here are + // non-fatal: the user is already authenticated and can finish + // configuring the profile from the /profile page. + const token = data.token + if (token) { + const trimmedDisplayName = displayName.trim() + if (trimmedDisplayName) { + try { + await fetch(getApiUrl("/api/auth/profile"), { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ display_name: trimmedDisplayName }), + }) + } catch (e) { + console.warn("[auth-setup] failed to save display_name:", e) + } + } + if (avatarFile) { + try { + await fetch(getApiUrl("/api/auth/profile/avatar"), { + method: "POST", + headers: { + "Content-Type": avatarFile.type, + Authorization: `Bearer ${token}`, + }, + body: avatarFile, + }) + } catch (e) { + console.warn("[auth-setup] failed to upload avatar:", e) + } + } + } + + // Release the local preview blob now that the file has been + // uploaded (or skipped). The header avatar pulls a fresh copy + // from the backend. + if (avatarPreviewUrl) { + URL.revokeObjectURL(avatarPreviewUrl) + setAvatarPreviewUrl(null) + } + + // Notify the header AvatarMenu (mounted on dashboard load with + // auth_enabled=false) to re-fetch its status + profile so the + // avatar appears immediately after first-time setup instead of + // requiring a page refresh. + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("proxmenux:profile-changed")) + } + setOpen(false) onComplete() } catch (err) { @@ -261,6 +336,100 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { + + {/* Optional profile decorations (Fase 2). Visually + separated from the mandatory credential fields by a + divider + a small heading so the operator understands + they can skip everything below and still complete the + setup. Both are saved with follow-up calls after the + setup endpoint returns the JWT. */} +
+

+ Profile · optional +

+ +
+ +
+ + setDisplayName(e.target.value)} + maxLength={64} + className="pl-10 text-base" + disabled={loading} + /> +
+

+ Leave empty to render the username itself. Up to 64 characters. +

+
+ +
+ +
+ {avatarPreviewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + {(displayName || username || "U").trim().charAt(0).toUpperCase() || "U"} + + )} +
+ { + const file = e.target.files?.[0] || null + handleAvatarChange(file) + if (fileInputRef.current) fileInputRef.current.value = "" + }} + /> +
+ + {avatarFile && ( + + )} +
+

+ PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results. +

+
+
+
+
diff --git a/AppImage/components/avatar-menu.tsx b/AppImage/components/avatar-menu.tsx new file mode 100644 index 00000000..7efdffe5 --- /dev/null +++ b/AppImage/components/avatar-menu.tsx @@ -0,0 +1,281 @@ +"use client" + +import { useEffect, useState } from "react" +import { User, Shield, LogOut } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu" +import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config" + +interface AuthStatus { + auth_enabled?: boolean + username?: string | null +} + +interface ProfileData { + success: boolean + username?: string | null + display_name?: string | null + has_avatar?: boolean + avatar_mtime?: number | null +} + +interface AvatarMenuProps { + /** Size of the avatar circle in the header trigger. */ + size?: "md" | "lg" + /** + * Callback used by the Security menu item. The Monitor renders its + * Settings/Security panels inside the same dashboard route, not on + * a separate URL, so navigation is handled by the parent that knows + * how to switch tabs. Optional — when omitted the menu item is hidden. + */ + onOpenSecurity?: () => void + /** + * Callback for "View profile". Same rationale: the parent decides how + * to route there (modal, page, tab switch). Until Fase 2 lands the + * caller typically passes an alert/toast that the page is coming. + */ + onOpenProfile?: () => void +} + +/** + * AvatarMenu — user/account dropdown for the header. + * + * Self-fetches the current auth status to derive the username and the + * initial that fills the avatar circle. Stays silent (renders nothing) + * when authentication is disabled on this install — no point showing + * an account menu for a "Sign out" that doesn't apply. + * + * Sign out clears the token from localStorage and reloads, mirroring + * the existing `handleLogout` in `security.tsx`. That keeps a single + * source of truth for the logout flow until Fase 2 introduces a + * proper /api/auth/logout that revokes the JWT server-side too. + */ +export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) { + // IMPORTANT — all hooks must run unconditionally on every render. The + // previous version short-circuited with `if (!auth_enabled) return null` + // BEFORE the avatar blob hooks, so the hook count changed between + // renders the moment auth status loaded → React error #310 ("rendered + // more hooks than during the previous render"). All `useState` and + // `useEffect` calls now live above any early return; the null branch + // is at the very end after the hooks. + const [status, setStatus] = useState(null) + const [profile, setProfile] = useState(null) + const [open, setOpen] = useState(false) + const [avatarBlobUrl, setAvatarBlobUrl] = useState(null) + + // Load both auth_status (to decide whether to render at all) and the + // profile (to render display_name + avatar). Profile is fetched only + // when auth is enabled — saves one roundtrip on installs without + // auth where the menu won't show anyway. + useEffect(() => { + let cancelled = false + fetchApi("/api/auth/status") + .then(data => { + if (cancelled) return + setStatus(data) + if (data?.auth_enabled && data?.username) { + fetchApi("/api/auth/profile") + .then(p => { + if (!cancelled) setProfile(p) + }) + .catch(() => { + // Profile fetch is best-effort. Falls back to username + initials. + }) + } + }) + .catch(() => { + if (!cancelled) setStatus(null) + }) + // Reload status + profile when the user updates the profile from + // the /profile page OR completes first-time auth setup. Refreshing + // status is what flips the menu visible after setup (when the + // initial mount saw auth_enabled=false); refreshing profile is + // what makes a new avatar/display name appear without a full + // browser refresh. + const handler = () => { + fetchApi("/api/auth/status") + .then(s => { + if (cancelled) return + setStatus(s) + if (s?.auth_enabled && s?.username) { + fetchApi("/api/auth/profile") + .then(p => { + if (!cancelled) setProfile(p) + }) + .catch(() => {}) + } + }) + .catch(() => {}) + } + if (typeof window !== "undefined") { + window.addEventListener("proxmenux:profile-changed", handler) + } + return () => { + cancelled = true + if (typeof window !== "undefined") { + window.removeEventListener("proxmenux:profile-changed", handler) + } + } + }, []) + + // Avatar fetch — the endpoint requires the Bearer header, which + // can't send, so we fetch as a blob and convert it to a + // local object URL for rendering. The blob URL is revoked on cleanup + // and on every refetch to avoid leaking memory. + 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]) + + // ── Hooks finished. Safe to early-return now. ── + // Hide the avatar entirely when auth isn't enabled on this install — + // there's no user identity to surface and no Sign out to offer. + if (!status?.auth_enabled || !status?.username) return null + + const username = status.username + const displayName = profile?.display_name || username + const initial = displayName.trim().charAt(0).toUpperCase() || "U" + + const handleSignOut = () => { + try { + localStorage.removeItem("proxmenux-auth-token") + localStorage.removeItem("proxmenux-auth-setup-complete") + } catch { + // localStorage may be unavailable (private mode); fall through. + } + window.location.reload() + } + + // Avatar size in the header trigger. The trigger has no chevron now — + // removing it freed enough horizontal space to bump the avatar a + // notch up (40 → 44 / 32 → 36) without nudging the Refresh / Theme + // buttons sitting to its left. + const avatarSize = size === "lg" ? "w-11 h-11 text-lg" : "w-9 h-9 text-sm" + + return ( + <> + {/* Backdrop overlay — dim only (no blur). Mounted while the + dropdown is open. `bg-black/40` dims the page enough to focus + attention on the dropdown without distorting the content + behind, which testers found annoying when full backdrop blur + was used (especially on wider desktop viewports). `z-40` + places it above the dashboard content but below the dropdown + portal (`DropdownMenuContent` lands on z-[60]) and below the + header (which stays on z-50 so the avatar trigger remains + clickable). Clicking the backdrop closes the menu — the + explicit `onClick` mirrors Radix's outside-click handler. */} + {open && ( +