Update AppImage 1.2.1.3

This commit is contained in:
MacRimi
2026-05-23 21:27:18 +02:00
parent 9d2685d4a8
commit 4b934db7db
15 changed files with 1911 additions and 95 deletions
+171 -2
View File
@@ -1,11 +1,11 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useRef } from "react"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog" import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { Label } from "./ui/label" 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" import { getApiUrl } from "../lib/api-config"
interface AuthSetupProps { interface AuthSetupProps {
@@ -22,6 +22,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = 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<File | null>(null)
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
const checkOnboardingStatus = async () => { 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 () => { const handleSetupAuth = async () => {
setError("") setError("")
@@ -125,6 +145,61 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
localStorage.removeItem("proxmenux-auth-declined") 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) setOpen(false)
onComplete() onComplete()
} catch (err) { } catch (err) {
@@ -261,6 +336,100 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
</Button> </Button>
</div> </div>
</div> </div>
{/* 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. */}
<div className="pt-3 border-t border-border/60 space-y-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Profile · optional
</p>
<div className="space-y-2">
<Label htmlFor="display-name" className="text-sm">
Display name
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="display-name"
type="text"
placeholder="Shown above the username in the menu"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
maxLength={64}
className="pl-10 text-base"
disabled={loading}
/>
</div>
<p className="text-[11px] text-muted-foreground">
Leave empty to render the username itself. Up to 64 characters.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">Avatar</Label>
<div className="flex items-center gap-3">
{avatarPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarPreviewUrl}
alt=""
className="w-14 h-14 rounded-full object-cover border border-border bg-cyan-500/5 shrink-0"
/>
) : (
<span className="w-14 h-14 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-xl font-semibold border border-border shrink-0">
{(displayName || username || "U").trim().charAt(0).toUpperCase() || "U"}
</span>
)}
<div className="flex flex-col gap-1.5 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] || null
handleAvatarChange(file)
if (fileInputRef.current) fileInputRef.current.value = ""
}}
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAvatarPick}
disabled={loading}
className="h-7 text-xs"
>
<Upload className="h-3 w-3 mr-1.5" />
{avatarFile ? "Change" : "Choose image"}
</Button>
{avatarFile && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAvatarChange(null)}
disabled={loading}
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
>
<Trash2 className="h-3 w-3 mr-1.5" />
Clear
</Button>
)}
</div>
<p className="text-[11px] text-muted-foreground">
PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.
</p>
</div>
</div>
</div>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
+281
View File
@@ -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<AuthStatus | null>(null)
const [profile, setProfile] = useState<ProfileData | null>(null)
const [open, setOpen] = useState(false)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(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<AuthStatus>("/api/auth/status")
.then(data => {
if (cancelled) return
setStatus(data)
if (data?.auth_enabled && data?.username) {
fetchApi<ProfileData>("/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<AuthStatus>("/api/auth/status")
.then(s => {
if (cancelled) return
setStatus(s)
if (s?.auth_enabled && s?.username) {
fetchApi<ProfileData>("/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
// <img src=…> 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 && (
<div
aria-hidden="true"
onClick={() => setOpen(false)}
className="fixed inset-0 z-40 bg-black/40 animate-in fade-in-0 duration-150"
/>
)}
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
aria-label="Open user menu"
// WebKit ignores `outline` for the tap-highlight overlay
// shown on iOS / Android Chrome after a touch. That overlay
// was the white border that lingered on the avatar after
// dismissing the dropdown without picking anything. Setting
// `-webkit-tap-highlight-color` to transparent suppresses
// it without affecting keyboard focus visibility (handled
// separately by `focus-visible:outline-none` above).
style={{ WebkitTapHighlightColor: "transparent" }}
>
{avatarBlobUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarBlobUrl}
alt=""
className={`${avatarSize} rounded-full object-cover bg-cyan-500/10`}
/>
) : (
<span
className={`${avatarSize} rounded-full flex items-center justify-center font-semibold bg-cyan-500/15 text-cyan-600 dark:text-cyan-300`}
>
{initial}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 z-[60]">
<DropdownMenuLabel>
<div className="flex items-center gap-3 py-1">
{avatarBlobUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarBlobUrl}
alt=""
className="w-20 h-20 rounded-full object-cover bg-cyan-500/10 shrink-0"
/>
) : (
<span className="w-20 h-20 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-3xl font-semibold shrink-0">
{initial}
</span>
)}
<div className="min-w-0">
<div className="text-base font-semibold truncate">{displayName}</div>
{profile?.display_name && (
<div className="text-xs text-muted-foreground truncate">{username}</div>
)}
{!profile?.display_name && (
<div className="text-xs text-muted-foreground truncate">Signed in</div>
)}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{onOpenProfile && (
<DropdownMenuItem onClick={onOpenProfile}>
<User className="h-4 w-4 mr-2" />
View profile
</DropdownMenuItem>
)}
{onOpenSecurity && (
<DropdownMenuItem onClick={onOpenSecurity}>
<Shield className="h-4 w-4 mr-2" />
Security
</DropdownMenuItem>
)}
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={handleSignOut}
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
>
<LogOut className="h-4 w-4 mr-2" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
+28 -15
View File
@@ -398,23 +398,36 @@ export function HealthThresholds() {
if (!leaf) return null if (!leaf) return null
const key = pathKey(path) const key = pathKey(path)
const editingValue = pending[key] ?? String(leaf.value) const editingValue = pending[key] ?? String(leaf.value)
// The input border carries the severity colour so the editable field // Visual rules (rebuilt — the original used /40 opacity borders +
// itself shows what kind of threshold this is — no separate badge // a blue ring stacked on top of the colour border, both of which
// duplicating the number, which users mistook for the "real" value. // were nearly invisible in read-only mode and stacked weirdly when
// `swap_critical` and any other `*_critical` leaf falls into the red // a value was customised):
// bucket via the substring check. A blue ring on top of the colour //
// border signals "customised vs recommended" — two independent // • Read-only mode (editMode=false): keep severity colour on the
// signals on the same widget. // border at a higher opacity (/70 instead of /40) and on the
// background (/10) so the field is clearly readable, and
// restore foreground colour (no `opacity-70` washout). This is
// the default state the user sees most of the time — it must
// match the visual weight of the rest of the Settings page.
// • Edit mode + value matches the recommended default: severity
// border + soft severity bg, same as read-only.
// • Edit mode + value customised: ONE border in blue, replacing
// (not stacking on top of) the severity border. This is the
// single signal that "this value differs from recommended".
//
// `swap_critical` and any other `*_critical` leaf falls into the
// red bucket via the substring check.
const last = path[path.length - 1] || "" const last = path[path.length - 1] || ""
const isCritical = last.toLowerCase().includes("critical") const isCritical = last.toLowerCase().includes("critical")
const isWarning = last.toLowerCase().includes("warning") const isWarning = last.toLowerCase().includes("warning")
const severityBorder = isCritical const severityClass = isCritical
? "border-red-500/40 bg-red-500/5 focus-visible:border-red-500" ? "border-red-500/70 bg-red-500/10 focus-visible:border-red-500"
: isWarning : isWarning
? "border-amber-500/40 bg-amber-500/5 focus-visible:border-amber-500" ? "border-amber-500/70 bg-amber-500/10 focus-visible:border-amber-500"
: "" : "border-input"
const isCustomised = leaf.customised && !(key in pending) const isCustomised = leaf.customised && !(key in pending)
const customisedRing = isCustomised ? "ring-2 ring-blue-500/40" : "" const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
const fieldClass = isCustomised ? customisedClass : severityClass
const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}` const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}`
return ( return (
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1"> <div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
@@ -433,9 +446,9 @@ export function HealthThresholds() {
onChange={(e) => onChange={(e) =>
setPending((p) => ({ ...p, [key]: e.target.value })) setPending((p) => ({ ...p, [key]: e.target.value }))
} }
className={`w-20 h-7 text-xs text-right tabular-nums ${ className={`w-20 h-7 text-xs text-right tabular-nums border ${fieldClass} ${
!editMode ? "opacity-70" : "" !editMode ? "disabled:opacity-100 disabled:cursor-default" : ""
} ${severityBorder} ${customisedRing}`} }`}
/> />
<span className="text-[11px] text-muted-foreground w-6">{leaf.unit}</span> <span className="text-[11px] text-muted-foreground w-6">{leaf.unit}</span>
</div> </div>
+18 -18
View File
@@ -104,10 +104,14 @@ export function LxcUpdateDetection() {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2"> {/* Title row — flex-wrap so on narrow screens the badge can drop
<Boxes className="h-5 w-5 text-purple-500" /> under the title without dragging the icon along with it. The
<CardTitle>LXC Update Detection</CardTitle> icon stays on the same baseline as the title text on every
breakpoint thanks to `items-center` + leading-tight title. */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
<Boxes className="h-5 w-5 text-purple-500 shrink-0" />
<CardTitle className="leading-tight">LXC Update Detection</CardTitle>
{enabled ? ( {enabled ? (
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500"> <Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
Active Active
@@ -118,7 +122,7 @@ export function LxcUpdateDetection() {
</Badge> </Badge>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
{saved && ( {saved && (
<span className="flex items-center gap-1 text-xs text-green-500"> <span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3.5 w-3.5" /> <CheckCircle2 className="h-3.5 w-3.5" />
@@ -172,23 +176,19 @@ export function LxcUpdateDetection() {
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
{/* ── Enable/Disable ── */} {/* ── Enable/Disable ── single-line label + toggle. The description
<div className="flex items-center justify-between py-2 px-1"> paragraph was removed because the CardDescription above already
<div className="flex items-center gap-2"> covers the behaviour; on mobile that second paragraph forced
the icon to top-align and made the toggle wrap awkwardly. */}
<div className="flex items-center justify-between gap-3 py-2 px-1">
<div className="flex items-center gap-2 min-w-0">
<Boxes <Boxes
className={`h-4 w-4 ${pending ? "text-purple-500" : "text-muted-foreground"}`} className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
/> />
<div> <span className="text-sm font-medium truncate">Enable LXC update detection</span>
<span className="text-sm font-medium">Enable LXC update detection</span>
<p className="text-[11px] text-muted-foreground">
When OFF, ProxMenux stops scanning your CTs (no <code>pct exec</code> calls), removes existing LXC
entries from the managed-installs registry, and hides the related notification toggle. Default is
ON.
</p>
</div>
</div> </div>
<button <button
className={`relative w-10 h-5 rounded-full transition-colors ${ className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
pending ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40" pending ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`} } ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => editMode && setPending(p => !p)} onClick={() => editMode && setPending(p => !p)}
+98 -2
View File
@@ -157,7 +157,7 @@ const EVENT_CATEGORIES = [
{ key: "other", label: "Other", desc: "Uncategorized notifications" }, { key: "other", label: "Other", desc: "Uncategorized notifications" },
] ]
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email"] as const const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "apprise"] as const
const AI_PROVIDERS = [ const AI_PROVIDERS = [
{ {
@@ -262,6 +262,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
gotify: { enabled: false }, gotify: { enabled: false },
discord: { enabled: false }, discord: { enabled: false },
email: { enabled: false }, email: { enabled: false },
apprise: { enabled: false },
}, },
event_categories: { event_categories: {
vm_ct: true, backup: true, resources: true, storage: true, vm_ct: true, backup: true, resources: true, storage: true,
@@ -275,6 +276,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
gotify: { categories: {}, events: {} }, gotify: { categories: {}, events: {} },
discord: { categories: {}, events: {} }, discord: { categories: {}, events: {} },
email: { categories: {}, events: {} }, email: { categories: {}, events: {} },
apprise: { categories: {}, events: {} },
}, },
ai_enabled: false, ai_enabled: false,
ai_provider: "groq", ai_provider: "groq",
@@ -305,6 +307,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
gotify: "brief", gotify: "brief",
discord: "brief", discord: "brief",
email: "detailed", email: "detailed",
apprise: "brief",
}, },
hostname: "", hostname: "",
webhook_secret: "", webhook_secret: "",
@@ -1346,7 +1349,7 @@ export function NotificationSettings() {
<div className="rounded-lg border border-border/50 bg-muted/20 p-3"> <div className="rounded-lg border border-border/50 bg-muted/20 p-3">
<Tabs defaultValue="telegram" className="w-full"> <Tabs defaultValue="telegram" className="w-full">
<TabsList className="w-full grid grid-cols-4 h-8"> <TabsList className="w-full grid grid-cols-5 h-8">
<TabsTrigger value="telegram" className="text-xs data-[state=active]:text-blue-500"> <TabsTrigger value="telegram" className="text-xs data-[state=active]:text-blue-500">
Telegram Telegram
</TabsTrigger> </TabsTrigger>
@@ -1359,6 +1362,9 @@ export function NotificationSettings() {
<TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500"> <TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500">
Email Email
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="apprise" className="text-xs data-[state=active]:text-cyan-500">
Apprise
</TabsTrigger>
</TabsList> </TabsList>
{/* Telegram */} {/* Telegram */}
@@ -1788,6 +1794,96 @@ export function NotificationSettings() {
</> </>
)} )}
</TabsContent> </TabsContent>
{/* Apprise — issue #207. Single URL talks to ~80
notification services. The operator pastes one
`tgram://`, `discord://`, `ntfy://`, `matrix://`,
`pushover://` etc. URL and the AppriseChannel
backend handles the transport. Mirrors the same
Enable toggle + Test button pattern as the other
channels. */}
<TabsContent value="apprise" className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">Enable Apprise</Label>
<a
href="https://github.com/caronc/apprise/wiki"
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-cyan-500 hover:text-cyan-400 hover:underline"
>
+URL formats
</a>
</div>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.channels.apprise?.enabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => { if (editMode) updateChannel("apprise", "enabled", !config.channels.apprise?.enabled) }}
disabled={!editMode}
role="switch"
aria-checked={config.channels.apprise?.enabled || false}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.apprise?.enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{config.channels.apprise?.enabled && (
<>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Apprise URL</Label>
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["apprise_url"] ? "text" : "password"}
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="tgram://bottoken/ChatID · ntfy://server/topic · discord://webhook_id/token · matrix://..."
value={config.channels.apprise?.url || ""}
onChange={e => updateChannel("apprise", "url", e.target.value)}
disabled={!editMode}
/>
<button
type="button"
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted text-muted-foreground"
onClick={() => setShowSecrets(s => ({ ...s, apprise_url: !s.apprise_url }))}
title={showSecrets["apprise_url"] ? "Hide URL" : "Show URL"}
>
{showSecrets["apprise_url"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
A single URL that Apprise routes to the right service. Examples:
<code className="text-foreground/80 mx-0.5">tgram://</code>,
<code className="text-foreground/80 mx-0.5">discord://</code>,
<code className="text-foreground/80 mx-0.5">slack://</code>,
<code className="text-foreground/80 mx-0.5">ntfy://</code>,
<code className="text-foreground/80 mx-0.5">matrix://</code>,
<code className="text-foreground/80 mx-0.5">pushover://</code>,
<code className="text-foreground/80 mx-0.5">mailto://</code>… See the
{" "}
<a
href="https://github.com/caronc/apprise/wiki"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-500 hover:underline"
>
full list
</a>.
</p>
</div>
<div className="flex justify-end pt-1">
<button
className="h-7 px-3 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={() => handleTest("apprise")}
disabled={testing === "apprise" || !config.channels.apprise?.url}
>
{testing === "apprise" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
Send Test
</button>
</div>
</>
)}
</TabsContent>
</Tabs> </Tabs>
{/* Test Result */} {/* Test Result */}
+467
View File
@@ -0,0 +1,467 @@
"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<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 || "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<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 || `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<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 || `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 (
<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" />
Loading profile
</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">Failed to load profile</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>User Profile</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" />
Saved
</span>
)}
{displayEditMode ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleCancelDisplay}
disabled={savingDisplay}
className="h-7 text-xs"
>
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" />
)}
Save
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={handleEditDisplay}
className="h-7 text-xs"
>
<Settings2 className="h-3 w-3 mr-1.5" />
Edit
</Button>
)}
</div>
</div>
<CardDescription>
Personal details rendered in the header avatar menu. None of this is required
the username already covers identity. Display name and avatar are decorative.
</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">Avatar</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 ? "Replace avatar" : "Upload avatar"}
</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" />
Remove avatar
</Button>
)}
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
PNG, JPEG, WebP or GIF. Up to 2 MB. The image isn&apos;t resized
render it square or pre-crop for best results in the header.
</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">Username</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">
The login name. To change it, disable authentication and reconfigure from
Security.
</p>
</div>
{/* ─── Display name (Edit controls live in the card header) ─── */}
<div>
<Label className="text-sm" htmlFor="profile-display">
Display name <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input
id="profile-display"
value={displayDraft}
onChange={(e) => 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"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Shown above the username inside the avatar menu. Leave empty to show the
username itself. Up to 64 characters.
</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>Account security</CardTitle>
</div>
<CardDescription>
Password, two-factor authentication and API tokens live in the Security panel.
</CardDescription>
</CardHeader>
<CardContent>
{onOpenSecurity ? (
<Button variant="outline" onClick={onOpenSecurity}>
<Lock className="h-4 w-4 mr-2" />
Open Security settings
</Button>
) : (
<p className="text-xs text-muted-foreground">
Open the Security tab from the navigation.
</p>
)}
</CardContent>
</Card>
</div>
)
}
+64 -20
View File
@@ -12,12 +12,14 @@ import Hardware from "./hardware"
import { SystemLogs } from "./system-logs" import { SystemLogs } from "./system-logs"
import { Settings } from "./settings" import { Settings } from "./settings"
import { Security } from "./security" import { Security } from "./security"
import { Profile } from "./profile"
import { About } from "./about" import { About } from "./about"
import { OnboardingCarousel } from "./onboarding-carousel" import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal" import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal" import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
import { getApiUrl, fetchApi } from "../lib/api-config" import { getApiUrl, fetchApi } from "../lib/api-config"
import { TerminalPanel } from "./terminal-panel" import { TerminalPanel } from "./terminal-panel"
import { AvatarMenu } from "./avatar-menu"
import { import {
RefreshCw, RefreshCw,
AlertTriangle, AlertTriangle,
@@ -368,6 +370,8 @@ export function ProxmoxDashboard() {
return "Security" return "Security"
case "settings": case "settings":
return "Settings" return "Settings"
case "profile":
return "Profile"
default: default:
return "Navigation Menu" return "Navigation Menu"
} }
@@ -480,44 +484,74 @@ export function ProxmoxDashboard() {
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<ThemeToggle /> <ThemeToggle />
</div> </div>
{/* User account dropdown — Fase 1 (v1.2.2). Self-hides
when auth isn't enabled on this install. */}
<div onClick={(e) => e.stopPropagation()}>
<AvatarMenu
size="lg"
onOpenProfile={() => setActiveTab("profile")}
onOpenSecurity={() => setActiveTab("security")}
/>
</div>
</div> </div>
{/* Mobile Actions */} {/* Mobile Actions — variant D approved in demo:
<div className="flex lg:hidden items-start gap-2 pt-2"> • Top-right: Refresh + Theme + Avatar (all with border)
<div className="flex flex-col items-end gap-1"> • Bottom row (under Node line): badges left-aligned with
<Badge variant="outline" className={`${statusColor} text-xs px-2`}> the Node text column, Uptime right-aligned in the same
{statusIcon} horizontal line. No extra row for Uptime so the
</Badge> header doesn't grow vertically. */}
{systemStatus.status === "healthy" && infoCount > 0 && ( <div className="flex lg:hidden items-center gap-1.5 shrink-0">
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
<Info className="h-4 w-4" />
<span className="ml-1">{infoCount}</span>
</Badge>
)}
</div>
<Button <Button
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
refreshData() refreshData()
}} }}
disabled={isRefreshing} disabled={isRefreshing}
className="h-8 w-8 p-0 -mt-1" className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary"
aria-label="Refresh"
> >
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button> </Button>
<div onClick={(e) => e.stopPropagation()} className="-mt-1"> <div onClick={(e) => e.stopPropagation()}>
<ThemeToggle /> <ThemeToggle />
</div> </div>
<div onClick={(e) => e.stopPropagation()}>
<AvatarMenu
size="lg"
onOpenProfile={() => setActiveTab("profile")}
onOpenSecurity={() => setActiveTab("security")}
/>
</div>
</div> </div>
</div> </div>
{/* Mobile Server Info */} {/* Mobile bottom row — badges (left, aligned with the title
<div className="lg:hidden mt-2 flex items-center justify-end text-xs text-muted-foreground"> column via pl-[3.25rem] = w-16 logo + space-x-2 gap-ish)
<span className="whitespace-nowrap">Uptime: {systemStatus.uptime || "N/A"}</span> and Uptime (right). The pl matches the mobile logo width
+ the parent flex gap so the badges sit visually under
"Node: amd", not flush against the screen edge. */}
<div className="lg:hidden mt-2 flex items-center justify-between gap-2 pl-[4.5rem]">
<div className="flex items-center gap-1.5">
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
{statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span>
</Badge>
{systemStatus.status === "healthy" && infoCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
<Info className="h-3 w-3" />
<span className="ml-1">{infoCount}</span>
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Uptime: {systemStatus.uptime || "N/A"}
</span>
</div> </div>
</div> </div>
</header> </header>
@@ -804,6 +838,16 @@ export function ProxmoxDashboard() {
<Security key={`security-${componentKey}`} /> <Security key={`security-${componentKey}`} />
</TabsContent> </TabsContent>
{/* Profile tab — not surfaced in the top tabs nav. The only
entry point is the avatar dropdown in the header (View
profile). v1.2.2 Fase 2. */}
<TabsContent value="profile" className="space-y-4 md:space-y-6 mt-0">
<Profile
key={`profile-${componentKey}`}
onOpenSecurity={() => setActiveTab("security")}
/>
</TabsContent>
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0"> <TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
<Settings /> <Settings />
</TabsContent> </TabsContent>
+8 -10
View File
@@ -6,7 +6,7 @@ import { Input } from "./ui/input"
import { Label } from "./ui/label" import { Label } from "./ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { import {
Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Key, Copy, Eye, EyeOff, Shield, Lock, User, AlertCircle, CheckCircle, Info, Key, Copy, Eye, EyeOff,
Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle, Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle,
Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban, Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban,
FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, ArrowDownLeft, ArrowUpRight, FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, ArrowDownLeft, ArrowUpRight,
@@ -925,11 +925,8 @@ export function Security() {
} }
} }
const handleLogout = () => { // handleLogout removed: the session-end action lives in the header's
localStorage.removeItem("proxmenux-auth-token") // AvatarMenu now (Fase 1, v1.2.2). See `components/avatar-menu.tsx`.
localStorage.removeItem("proxmenux-auth-setup-complete")
window.location.reload()
}
const loadApiTokens = async () => { const loadApiTokens = async () => {
try { try {
@@ -1740,10 +1737,11 @@ ${(report.sections && report.sections.length > 0) ? `
{authEnabled && ( {authEnabled && (
<div className="space-y-3"> <div className="space-y-3">
<Button onClick={handleLogout} variant="outline" className="bg-transparent"> {/* Logout moved to the header AvatarMenu (Fase 1, v1.2.2)
<LogOut className="h-4 w-4 mr-2" /> so the session-end action lives in one consistent place
Logout on every page. The Security panel keeps the actions
</Button> that affect the *account* itself (password, 2FA, disable
auth), not the session. */}
{!showChangePassword && ( {!showChangePassword && (
<Button onClick={() => setShowChangePassword(true)} variant="outline"> <Button onClick={() => setShowChangePassword(true)} variant="outline">
+235 -23
View File
@@ -8,7 +8,7 @@ import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress" import { Progress } from "./ui/progress"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package } from 'lucide-react' import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw } from 'lucide-react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Checkbox } from "./ui/checkbox" import { Checkbox } from "./ui/checkbox"
import { Textarea } from "./ui/textarea" import { Textarea } from "./ui/textarea"
@@ -645,7 +645,16 @@ export function VirtualMachines() {
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default") const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
// Tab state for modal // Tab state for modal
const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups" | "updates">("status") const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups" | "updates" | "firewall">("status")
// Firewall log state — fetched only when the operator opens that tab
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
// modal open. Issue #14554 from the helper-scripts discussions.
interface FirewallLogEntry { n: number; t: string }
const [firewallLogs, setFirewallLogs] = useState<FirewallLogEntry[]>([])
const [loadingFirewallLog, setLoadingFirewallLog] = useState(false)
const [firewallEnabled, setFirewallEnabled] = useState<boolean>(true)
const [firewallLogError, setFirewallLogError] = useState<string | null>(null)
// Sprint 13.29: per-LXC mount points lazy-loaded when the user opens // Sprint 13.29: per-LXC mount points lazy-loaded when the user opens
// the LXC modal. We fetch alongside backups (one-shot) so switching // the LXC modal. We fetch alongside backups (one-shot) so switching
// tabs is instantaneous; the cost is small (parses one config file // tabs is instantaneous; the cost is small (parses one config file
@@ -772,6 +781,11 @@ export function VirtualMachines() {
// so the new modal doesn't briefly flash data from another LXC. // so the new modal doesn't briefly flash data from another LXC.
setMountPoints([]) setMountPoints([])
setAdHocMounts([]) setAdHocMounts([])
// Reset firewall log state — fetched lazily when the user opens
// that tab, since most operators won't visit it on every modal open.
setFirewallLogs([])
setFirewallLogError(null)
setFirewallEnabled(true)
// Load backups immediately (independent of config) // Load backups immediately (independent of config)
fetchBackupStorages() fetchBackupStorages()
@@ -858,6 +872,33 @@ export function VirtualMachines() {
} }
} }
// Firewall log fetcher — proxies the PVE per-VM/CT firewall log
// endpoint. The backend returns `firewall_enabled: false` when PVE
// says the firewall is OFF for that guest; in that case we render
// a callout instead of an empty viewer.
const fetchFirewallLog = async (vmid: number) => {
setLoadingFirewallLog(true)
setFirewallLogError(null)
try {
const response = await fetchApi<{
logs?: FirewallLogEntry[]
firewall_enabled?: boolean
error?: string
}>(`/api/vms/${vmid}/firewall/log?limit=500`)
setFirewallEnabled(response.firewall_enabled !== false)
setFirewallLogs(Array.isArray(response.logs) ? response.logs : [])
if (response.error && response.firewall_enabled !== false) {
setFirewallLogError(response.error)
}
} catch (error) {
setFirewallEnabled(true)
setFirewallLogs([])
setFirewallLogError(error instanceof Error ? error.message : String(error))
} finally {
setLoadingFirewallLog(false)
}
}
const openBackupModal = () => { const openBackupModal = () => {
// Reset modal to defaults // Reset modal to defaults
setBackupMode("snapshot") setBackupMode("snapshot")
@@ -1708,62 +1749,127 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{/* Tab Navigation */} {/* Tab Navigation.
<div className="flex border-b border-border px-6 shrink-0"> Mobile UX:
• Only the active tab shows its label; the rest
collapse to icon-only so 4-5 tabs fit on a phone.
• Per-tab padding + gap shrink on narrow viewports
(`px-2.5 sm:px-4`, `gap-1.5 sm:gap-2`) so even with
two badges showing counts the row doesn't overflow.
• Container has `overflow-x-auto` as a safety net —
a CT with all tabs active (Mounts + Backups +
Updates + Firewall) on a very narrow phone can
still horizontally scroll the row instead of
clipping the last tab off-screen.
• Badges stay visible in both states so the user
still sees "9 backups" at a glance even when that
tab isn't active. */}
<div className="flex border-b border-border px-3 sm:px-6 shrink-0 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<button <button
onClick={() => setActiveModalTab("status")} onClick={() => setActiveModalTab("status")}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${ className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "status" activeModalTab === "status"
? "border-cyan-500 text-cyan-500" ? "border-cyan-500 text-cyan-500"
: "border-transparent text-muted-foreground hover:text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
}`} }`}
> >
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
Status <span className={activeModalTab === "status" ? "" : "hidden sm:inline"}>
Status
</span>
</button> </button>
{/* Sprint 13.29: Mount Points tab — LXC only, and only {/* Sprint 13.29: Mount Points tab — LXC only, and only
when at least one mp / ad-hoc remote mount exists. when at least one mp / ad-hoc remote mount exists.
A CT without mounts gets no empty tab. A CT without mounts gets no empty tab. */}
Label is "Mounts" (single word) so the trigger
fits in one line on mobile next to Status and
Backups; "Mount Points" wrapped on narrow viewports. */}
{selectedVM?.type === "lxc" && (mountPoints.length > 0 || adHocMounts.length > 0) && ( {selectedVM?.type === "lxc" && (mountPoints.length > 0 || adHocMounts.length > 0) && (
<button <button
onClick={() => setActiveModalTab("mounts")} onClick={() => setActiveModalTab("mounts")}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${ className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "mounts" activeModalTab === "mounts"
? "border-blue-500 text-blue-500" ? "border-blue-500 text-blue-500"
: "border-transparent text-muted-foreground hover:text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
}`} }`}
> >
<HardDrive className="h-4 w-4" /> <HardDrive className="h-4 w-4" />
Mounts <span className={activeModalTab === "mounts" ? "" : "hidden sm:inline"}>
<Badge variant="secondary" className="text-xs h-5 ml-1"> Mounts
</span>
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">
{mountPoints.length + adHocMounts.length} {mountPoints.length + adHocMounts.length}
</Badge> </Badge>
</button> </button>
)} )}
<button <button
onClick={() => setActiveModalTab("backups")} onClick={() => setActiveModalTab("backups")}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${ className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "backups" activeModalTab === "backups"
? "border-amber-500 text-amber-500" ? "border-amber-500 text-amber-500"
: "border-transparent text-muted-foreground hover:text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
}`} }`}
> >
<Archive className="h-4 w-4" /> <Archive className="h-4 w-4" />
Backups <span className={activeModalTab === "backups" ? "" : "hidden sm:inline"}>
Backups
</span>
{vmBackups.length > 0 && ( {vmBackups.length > 0 && (
<Badge variant="secondary" className="text-xs h-5 ml-1">{vmBackups.length}</Badge> <Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">{vmBackups.length}</Badge>
)} )}
</button> </button>
{/* Updates is intentionally NOT a tab in the nav — the {/* Updates tab — re-added as a first-class nav entry now
extra tab created a scrolling tab strip on mobile that the mobile UX collapses inactive tabs to
(especially once Mounts + Backups + Updates piled icon-only (so the row no longer overflows on narrow
up) and the swipe affordance was missed. The viewports the way it did before v1.2.1.3). LXC only,
clickable violet badge in the modal header is now rendered only when the managed-installs registry has
the sole entry point; the Updates content panel flagged pending updates for this CT, so a CT with
below still mounts when activeModalTab === 'updates'. */} nothing pending doesn't get an empty tab. The violet
badge in the header stays as a complementary entry
point — both routes lead to the same `updates` panel
below. */}
{selectedVM?.type === "lxc" && selectedVM?.update_check?.available && (
<button
onClick={() => setActiveModalTab("updates")}
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "updates"
? "border-purple-500 text-purple-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<RefreshCw className="h-4 w-4" />
<span className={activeModalTab === "updates" ? "" : "hidden sm:inline"}>
Updates
</span>
{typeof selectedVM.update_check?.count === "number" && selectedVM.update_check.count > 0 && (
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">
{selectedVM.update_check.count}
</Badge>
)}
</button>
)}
{/* Firewall tab — issue #14554 from the helper-scripts
discussions ("view individual VM/CT firewall logs").
Always rendered for VMs and CTs; if the guest doesn't
have firewall enabled in PVE, the panel shows a
callout explaining how to turn it on. Log fetched
lazily on first click to avoid hitting pvesh on
every modal open. */}
{selectedVM && (
<button
onClick={() => {
setActiveModalTab("firewall")
fetchFirewallLog(selectedVM.vmid)
}}
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "firewall"
? "border-orange-500 text-orange-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Shield className="h-4 w-4" />
<span className={activeModalTab === "firewall" ? "" : "hidden sm:inline"}>
Firewall
</span>
</button>
)}
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0"> <div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
@@ -2740,6 +2846,112 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</Card> </Card>
</div> </div>
)} )}
{/* Firewall Logs Tab — issue #14554. Reads the per-VM/CT
log filtered by PVE directly (no host-wide log
grep). Loading is lazy and triggered by the tab
button's onClick. */}
{activeModalTab === "firewall" && (
<div className="space-y-4">
<Card className="border border-border bg-card/50">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-orange-500/10">
<Shield className="h-4 w-4 text-orange-500" />
</div>
<h3 className="text-sm font-semibold text-foreground">Firewall Logs</h3>
{firewallEnabled && firewallLogs.length > 0 && (
<Badge variant="secondary" className="text-xs h-5 ml-1">
{firewallLogs.length}
</Badge>
)}
</div>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={() => selectedVM && fetchFirewallLog(selectedVM.vmid)}
disabled={loadingFirewallLog}
>
{loadingFirewallLog ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
<span>Refresh</span>
</Button>
</div>
<div className="border-t border-border/50 mb-4" />
{loadingFirewallLog ? (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">Loading firewall log</span>
</div>
) : !firewallEnabled ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm">
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="font-medium text-amber-500">
Firewall is not enabled for this {selectedVM?.type === "lxc" ? "container" : "VM"}
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Enable it in the Proxmox UI under{" "}
<strong>
{selectedVM?.type === "lxc" ? "Container" : "VM"} Firewall Options
</strong>{" "}
and add at least one rule with <code>log: info</code> (or higher) so packets start
being recorded. New entries will appear here automatically on the next refresh.
</p>
</div>
</div>
</div>
) : firewallLogError ? (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm">
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-500 mb-1">Failed to read firewall log</p>
<p className="text-xs text-muted-foreground break-all">{firewallLogError}</p>
</div>
</div>
</div>
) : firewallLogs.length === 0 ? (
<div className="text-center py-6 text-sm text-muted-foreground">
No firewall events recorded yet.
<div className="text-xs mt-1">
Rules with <code>log: info</code> (or higher) will populate this view as packets arrive.
</div>
</div>
) : (
<div className="rounded-md border border-border bg-background/50 max-h-[480px] overflow-y-auto">
<pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
{firewallLogs.map((entry, idx) => {
const text = entry.t || ""
// Light colour-coding by the action keyword
// PVE emits in the line itself — purely
// visual, parsing stays line-by-line so
// a malformed entry still renders fine.
let actionClass = "text-foreground/90"
if (/\bDROP\b/i.test(text)) actionClass = "text-red-400"
else if (/\bREJECT\b/i.test(text)) actionClass = "text-orange-400"
else if (/\bACCEPT\b/i.test(text)) actionClass = "text-green-400"
return (
<div key={`${entry.n}-${idx}`} className={actionClass}>
{text}
</div>
)
})}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div> </div>
<div className="border-t border-border bg-background px-6 py-4 mt-auto shrink-0"> <div className="border-t border-border bg-background px-6 py-4 mt-auto shrink-0">
+184 -2
View File
@@ -39,6 +39,20 @@ except ImportError:
# Configuration # Configuration
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor" CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json" AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
# User profile — Fase 2 (v1.2.2). Avatar stored as a binary file next
# to auth.json so the JSON stays small and the image can be served
# unmodified. Display name is kept inside auth.json as an optional
# string; empty/missing falls back to the username at render time.
AVATAR_FILE = CONFIG_DIR / "avatar.bin"
AVATAR_CONTENT_TYPE_FILE = CONFIG_DIR / "avatar.type"
AVATAR_MAX_BYTES = 2 * 1024 * 1024 # 2 MB hard cap on uploads
AVATAR_ALLOWED_CONTENT_TYPES = {
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
}
# Sentinel for legacy installs that started under the hardcoded JWT_SECRET. # Sentinel for legacy installs that started under the hardcoded JWT_SECRET.
# The audit (Tier 4 #22) flagged that constant — anyone with access to the # The audit (Tier 4 #22) flagged that constant — anyone with access to the
# public repo could forge JWTs against any deployment. We now generate a # public repo could forge JWTs against any deployment. We now generate a
@@ -97,7 +111,8 @@ def load_auth_config():
"totp_secret": None, "totp_secret": None,
"backup_codes": [], "backup_codes": [],
"api_tokens": [], "api_tokens": [],
"revoked_tokens": [] "revoked_tokens": [],
"display_name": None,
} }
try: try:
@@ -111,6 +126,7 @@ def load_auth_config():
config.setdefault("backup_codes", []) config.setdefault("backup_codes", [])
config.setdefault("api_tokens", []) config.setdefault("api_tokens", [])
config.setdefault("revoked_tokens", []) config.setdefault("revoked_tokens", [])
config.setdefault("display_name", None)
return config return config
except Exception as e: except Exception as e:
print(f"Error loading auth config: {e}") print(f"Error loading auth config: {e}")
@@ -124,7 +140,8 @@ def load_auth_config():
"totp_secret": None, "totp_secret": None,
"backup_codes": [], "backup_codes": [],
"api_tokens": [], "api_tokens": [],
"revoked_tokens": [] "revoked_tokens": [],
"display_name": None,
} }
@@ -1280,3 +1297,168 @@ def authenticate(username, password, totp_token=None):
return True, token, False, "Authentication successful" return True, token, False, "Authentication successful"
else: else:
return False, None, False, "Failed to generate authentication token" return False, None, False, "Failed to generate authentication token"
# ---------------------------------------------------------------------------
# User profile (Fase 2, v1.2.2)
# ---------------------------------------------------------------------------
#
# Display name + avatar. Both are optional decorations on top of the
# existing username + password. The display name lives inside auth.json
# (one extra string field). The avatar is stored as a binary file next
# to auth.json so the JSON stays small and the image can be served
# without re-encoding.
#
# No email field — the Monitor doesn't send mail (no password reset, no
# confirmation), and the operator-of-PVE-as-root use case never benefits
# from one. If OIDC lands in v1.3.0 we'll surface whatever the issuer
# claims, but we don't ask the operator for an email manually.
def get_user_profile():
"""Return the active user's profile decorations.
Returns a dict with:
{
"username": str | None,
"display_name": str | None, # may equal username
"has_avatar": bool,
"avatar_mtime": float | None, # for cache-busting URLs
"avatar_content_type": str | None,
}
Username falls back to None when auth isn't configured/enabled.
"""
config = load_auth_config()
username = config.get("username") if config.get("enabled") else None
display_name = config.get("display_name") or None
has_avatar = AVATAR_FILE.exists() and AVATAR_FILE.stat().st_size > 0
avatar_mtime = None
avatar_content_type = None
if has_avatar:
try:
avatar_mtime = AVATAR_FILE.stat().st_mtime
except OSError:
avatar_mtime = None
try:
if AVATAR_CONTENT_TYPE_FILE.exists():
avatar_content_type = AVATAR_CONTENT_TYPE_FILE.read_text().strip() or None
except OSError:
avatar_content_type = None
return {
"username": username,
"display_name": display_name,
"has_avatar": has_avatar,
"avatar_mtime": avatar_mtime,
"avatar_content_type": avatar_content_type,
}
def set_display_name(display_name):
"""Persist (or clear) the user's display name.
Accepts any string up to 64 chars. An empty / whitespace-only value
clears the field — the dropdown then falls back to the raw username
when rendering. Returns (success: bool, message: str).
"""
cleaned = (display_name or "").strip()
if len(cleaned) > 64:
return False, "Display name must be 64 characters or less"
# Disallow control characters — a display name with embedded \n
# would break the avatar dropdown layout.
if any(ord(ch) < 0x20 for ch in cleaned):
return False, "Display name contains control characters"
config = load_auth_config()
config["display_name"] = cleaned or None
if not save_auth_config(config):
return False, "Failed to save profile"
return True, "Display name updated"
def save_avatar(content_bytes, content_type):
"""Persist a new avatar image. Best-effort validation:
• Content-Type must be one of `AVATAR_ALLOWED_CONTENT_TYPES`.
• Size must be <= `AVATAR_MAX_BYTES` (2 MB).
• Magic-number check — first few bytes must match a supported image
format. This blocks a `.png`-renamed `.exe` from being served as
an image to other browsers.
Returns (success: bool, message: str). Does not resize — the
frontend always renders the avatar inside a `rounded-full` with
`object-cover`, so any aspect ratio displays correctly. Operators
who want a smaller file can compress before upload.
"""
if not isinstance(content_bytes, (bytes, bytearray)) or not content_bytes:
return False, "No image data"
if len(content_bytes) > AVATAR_MAX_BYTES:
return False, f"Image exceeds {AVATAR_MAX_BYTES // (1024 * 1024)} MB limit"
if content_type not in AVATAR_ALLOWED_CONTENT_TYPES:
return False, f"Unsupported image type: {content_type}"
# Magic-number sniffing: trust the Content-Type but verify.
head = bytes(content_bytes[:12])
looks_valid = (
head.startswith(b"\x89PNG\r\n\x1a\n") or # PNG
head.startswith(b"\xff\xd8\xff") or # JPEG
(head[:4] == b"RIFF" and head[8:12] == b"WEBP") or # WebP
head.startswith(b"GIF87a") or head.startswith(b"GIF89a") # GIF
)
if not looks_valid:
return False, "Image bytes don't match a supported format"
try:
ensure_config_dir()
# Write atomically — tmp + rename so a crashed write never leaves
# a half-written avatar file that the GET endpoint would serve as
# corrupt bytes.
tmp_avatar = AVATAR_FILE.with_suffix(AVATAR_FILE.suffix + ".tmp")
with open(tmp_avatar, "wb") as f:
f.write(content_bytes)
os.replace(tmp_avatar, AVATAR_FILE)
AVATAR_CONTENT_TYPE_FILE.write_text(content_type)
try:
os.chmod(AVATAR_FILE, 0o600)
except OSError:
# Best-effort permission tighten; not fatal if the FS doesn't
# support it (e.g. some bind-mounted scenarios).
pass
return True, "Avatar saved"
except Exception as e:
return False, f"Failed to save avatar: {e}"
def delete_avatar():
"""Remove the stored avatar file. Returns (success, message). No-op
when there's nothing to delete (still returns success)."""
try:
if AVATAR_FILE.exists():
AVATAR_FILE.unlink()
if AVATAR_CONTENT_TYPE_FILE.exists():
AVATAR_CONTENT_TYPE_FILE.unlink()
return True, "Avatar removed"
except Exception as e:
return False, f"Failed to remove avatar: {e}"
def get_avatar_bytes():
"""Return (bytes, content_type) for the stored avatar, or (None, None)
if no avatar is set or the file is unreadable. The caller is
responsible for the HTTP response; this only handles the I/O."""
if not AVATAR_FILE.exists():
return None, None
try:
data = AVATAR_FILE.read_bytes()
except OSError:
return None, None
content_type = "application/octet-stream"
try:
if AVATAR_CONTENT_TYPE_FILE.exists():
ct = AVATAR_CONTENT_TYPE_FILE.read_text().strip()
if ct in AVATAR_ALLOWED_CONTENT_TYPES:
content_type = ct
except OSError:
pass
return data, content_type
+8
View File
@@ -383,6 +383,14 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
gevent-websocket>=0.10.1 \ gevent-websocket>=0.10.1 \
greenlet>=3.0.0 greenlet>=3.0.0
# Phase 3c: Apprise notification hub (issue #207). One library handles
# ~80 notification services behind a single URL scheme (`tgram://`,
# `discord://`, `ntfy://`, `matrix://`, etc.). Used by the optional
# `apprise` channel in notification_channels.py for operators who want
# to reach a service we don't support natively.
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
apprise>=1.7.0
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF' cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
from typing import Tuple, Dict from typing import Tuple, Dict
try: try:
+125
View File
@@ -670,3 +670,128 @@ def revoke_api_token_route(token_id):
return jsonify({"success": False, "message": message}), 400 return jsonify({"success": False, "message": message}), 400
except Exception as e: except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500 return jsonify({"success": False, "message": str(e)}), 500
# ---------------------------------------------------------------------------
# User profile endpoints (Fase 2, v1.2.2)
# ---------------------------------------------------------------------------
#
# GET /api/auth/profile → username + display_name + has_avatar
# PUT /api/auth/profile → update display_name (body: {display_name})
# GET /api/auth/profile/avatar → serve the avatar bytes (image/*)
# POST /api/auth/profile/avatar → upload new avatar (multipart 'file')
# DELETE /api/auth/profile/avatar → remove the stored avatar
#
# All four require auth via @require_auth. The avatar GET also requires
# auth because the file lives next to the auth state on disk and we
# don't want it leaked to arbitrary callers — the avatar URL is meant
# to be fetched by an already-authenticated session.
@auth_bp.route('/api/auth/profile', methods=['GET'])
@require_auth
def get_profile():
"""Return the active user's profile (username + display name + avatar
metadata). Falls back to None values when auth isn't configured."""
try:
profile = auth_manager.get_user_profile()
return jsonify({
"success": True,
**profile,
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/profile', methods=['PUT'])
@require_auth
def update_profile():
"""Update display_name. Body: {"display_name": "..."}. Empty string
clears it (the dropdown then renders the raw username)."""
try:
data = request.get_json(silent=True) or {}
if "display_name" not in data:
return jsonify({
"success": False,
"message": "Missing 'display_name' field",
}), 400
ok, message = auth_manager.set_display_name(data.get("display_name") or "")
if not ok:
return jsonify({"success": False, "message": message}), 400
# Return the fresh profile so the frontend can update without a
# second roundtrip.
return jsonify({"success": True, "message": message, **auth_manager.get_user_profile()})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/profile/avatar', methods=['GET'])
@require_auth
def get_avatar():
"""Serve the stored avatar bytes. Returns 404 if no avatar set."""
try:
from flask import Response
data, content_type = auth_manager.get_avatar_bytes()
if data is None:
return jsonify({"success": False, "message": "No avatar set"}), 404
return Response(
data,
mimetype=content_type,
headers={
# Allow short-window caching keyed by the URL — the
# frontend appends `?v=<mtime>` so any update busts the
# cache automatically.
"Cache-Control": "private, max-age=60",
},
)
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/profile/avatar', methods=['POST'])
@require_auth
def upload_avatar():
"""Upload a new avatar image. Accepts either:
• multipart/form-data with a `file` field (preferred), or
• a raw image body with Content-Type set to image/png|jpeg|webp|gif.
The size cap (2 MB) and the magic-number sniff happen in
auth_manager.save_avatar — failures come back as 400 with a
human-readable message."""
try:
content_bytes = None
content_type = None
# Multipart path
if request.files:
file_storage = request.files.get("file")
if file_storage is not None:
content_bytes = file_storage.read()
content_type = (file_storage.mimetype or "").lower()
# Raw body fallback
if content_bytes is None:
content_bytes = request.get_data(cache=False)
content_type = (request.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
if not content_bytes:
return jsonify({"success": False, "message": "No image data received"}), 400
ok, message = auth_manager.save_avatar(content_bytes, content_type)
if not ok:
return jsonify({"success": False, "message": message}), 400
return jsonify({"success": True, "message": message, **auth_manager.get_user_profile()})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/profile/avatar', methods=['DELETE'])
@require_auth
def remove_avatar():
"""Remove the stored avatar (no-op if none set)."""
try:
ok, message = auth_manager.delete_avatar()
if not ok:
return jsonify({"success": False, "message": message}), 400
return jsonify({"success": True, "message": message, **auth_manager.get_user_profile()})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@@ -34,6 +34,7 @@ TOOL_METADATA = {
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'}, 'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'}, 'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'}, 'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'},
'amd_fixes': {'name': 'AMD CPU (Ryzen/EPYC) fixes', 'function': 'apply_amd_fixes', 'version': '1.0'}, 'amd_fixes': {'name': 'AMD CPU (Ryzen/EPYC) fixes', 'function': 'apply_amd_fixes', 'version': '1.0'},
'persistent_network': {'name': 'Setting persistent network interfaces', 'function': 'setup_persistent_network', 'version': '1.0'}, 'persistent_network': {'name': 'Setting persistent network interfaces', 'function': 'setup_persistent_network', 'version': '1.0'},
'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'}, 'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'},
+99
View File
@@ -11198,6 +11198,105 @@ def api_vm_logs(vmid):
pass pass
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/firewall/log', methods=['GET'])
@require_auth
def api_vm_firewall_log(vmid):
"""Per-VM/CT firewall log entries — proxies the official PVE API:
`/nodes/<node>/{lxc,qemu}/<vmid>/firewall/log`. Returns the matching
lines from `/var/log/pve-firewall.log` already filtered by VMID so
the frontend doesn't have to parse the host-wide log itself.
Implements issue #14554 from the helper-scripts discussions —
"view individual VM/CT firewall logs" without writing any custom
log parsing: PVE's API does it natively.
Query string:
* `start` 0-based offset into the log (default 0).
* `limit` number of lines to return (default 500, cap 5000).
Response shape mirrors the `/api/vms/<vmid>/logs` endpoint so the
frontend log viewer can reuse the same renderer.
"""
try:
start = max(int(request.args.get('start', 0)), 0)
limit = min(max(int(request.args.get('limit', 500)), 1), 5000)
except (TypeError, ValueError):
return jsonify({'error': 'start/limit must be integers'}), 400
try:
resources = get_cached_pvesh_cluster_resources_vm()
if not resources:
return jsonify({'error': 'Failed to enumerate cluster VMs'}), 500
vm_info = next((r for r in resources if r.get('vmid') == vmid), None)
if not vm_info:
return jsonify({'error': f'VM/LXC {vmid} not found'}), 404
vm_type = 'lxc' if vm_info.get('type') == 'lxc' else 'qemu'
node = vm_info.get('node', 'pve')
log_result = subprocess.run(
[
'pvesh', 'get',
f'/nodes/{node}/{vm_type}/{vmid}/firewall/log',
'--start', str(start),
'--limit', str(limit),
'--output-format', 'json',
],
capture_output=True, text=True, timeout=10,
)
if log_result.returncode != 0:
stderr = (log_result.stderr or '').strip()
# PVE returns this exact wording when the firewall is OFF
# for the guest — surface it as a structured flag so the
# frontend can render a "firewall disabled" callout instead
# of a generic error toast.
firewall_disabled = (
'firewall' in stderr.lower() and 'disable' in stderr.lower()
) or '404' in stderr
return jsonify({
'vmid': vmid,
'name': vm_info.get('name'),
'type': vm_type,
'node': node,
'firewall_enabled': not firewall_disabled,
'logs': [],
'error': stderr[:300] if stderr else 'pvesh returned non-zero',
}), 200 if firewall_disabled else 500
entries = []
try:
data = json.loads(log_result.stdout or '[]')
if isinstance(data, list):
for row in data:
if isinstance(row, dict):
entries.append({
'n': row.get('n'),
't': row.get('t', ''),
})
except (json.JSONDecodeError, ValueError):
# Older PVE versions or oddly-shaped output: fall back to
# plain text parsing, one entry per line.
for i, line in enumerate(log_result.stdout.split('\n')):
if line.strip():
entries.append({'n': start + i, 't': line})
return jsonify({
'vmid': vmid,
'name': vm_info.get('name'),
'type': vm_type,
'node': node,
'firewall_enabled': True,
'log_lines': len(entries),
'logs': entries,
})
except subprocess.TimeoutExpired:
return jsonify({'error': 'pvesh timed out reading firewall log'}), 504
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/control', methods=['POST']) @app.route('/api/vms/<int:vmid>/control', methods=['POST'])
@require_auth @require_auth
def api_vm_control(vmid): def api_vm_control(vmid):
+122 -1
View File
@@ -1021,6 +1021,120 @@ class EmailChannel(NotificationChannel):
return result.get('success', False), result.get('error', '') return result.get('success', False), result.get('error', '')
# ─── Apprise ─────────────────────────────────────────────────────
class AppriseChannel(NotificationChannel):
"""Apprise meta-channel — a single URL talks to ~80 services.
Apprise (https://github.com/caronc/apprise) is a Python library that
normalises a wide catalogue of notification destinations behind a
single URL scheme: `tgram://`, `discord://`, `slack://`, `gotify://`,
`ntfy://`, `matrix://`, `mailto://`, `pushover://`, `signal://`, etc.
The operator pastes one URL and ProxMenux delegates the transport.
Requested in issue #207 by @0berkampf. Implemented as a *separate
channel type* (not a replacement for the native Telegram / Gotify /
Discord / Email channels), so installs that already have a working
native channel don't need to migrate — Apprise is opt-in for users
who want to reach a service we don't support natively.
The library is loaded lazily on first send. Older deployments that
haven't installed it yet surface a clean validation error instead
of crashing the notification manager at import time.
"""
def __init__(self, url: str):
super().__init__()
self.url = (url or '').strip()
# Lazy import so installs that haven't picked up the new dep yet
# don't crash on module load. Each call re-imports cheaply — Python
# caches the module reference after the first hit.
def _load_apprise(self):
try:
import apprise # type: ignore
return apprise
except ImportError:
return None
def validate_config(self) -> Tuple[bool, str]:
if not self.url:
return False, 'Apprise URL is required'
apprise = self._load_apprise()
if apprise is None:
return False, (
'apprise library not installed in this deployment. '
'Reinstall ProxMenux Monitor or run `pip install apprise` '
'inside the AppImage environment.'
)
# `add(url)` returns True only if Apprise recognised the scheme
# — useful as a syntactic validation without sending anything.
try:
apobj = apprise.Apprise()
ok = apobj.add(self.url)
if not ok:
return False, 'Apprise rejected the URL (unrecognised scheme or bad format)'
except Exception as e:
return False, f'Apprise rejected the URL: {e}'
return True, ''
def _severity_to_notify_type(self, apprise_mod, severity: str):
"""Map ProxMenux severities to Apprise NotifyType constants so
services that render severity (e.g. Pushover priority, ntfy
priority headers) get the right indicator."""
sev = (severity or '').upper()
if sev == 'CRITICAL':
return apprise_mod.NotifyType.FAILURE
if sev == 'WARNING':
return apprise_mod.NotifyType.WARNING
if sev == 'SUCCESS':
return apprise_mod.NotifyType.SUCCESS
return apprise_mod.NotifyType.INFO
def send(self, title: str, message: str, severity: str = 'INFO',
data: Optional[Dict] = None) -> Dict[str, Any]:
ok, err = self.validate_config()
if not ok:
return {'success': False, 'error': err, 'channel': 'apprise'}
# Rate limit (shared with the other channels) before dispatch.
def _send_via_apprise() -> Tuple[int, str]:
apprise = self._load_apprise()
if apprise is None:
# Shouldn't happen — validate_config caught it above —
# but defend in depth so the retry loop reports cleanly.
return 0, 'apprise library not available'
try:
apobj = apprise.Apprise()
apobj.add(self.url)
sent = apobj.notify(
body=message or '',
title=title or '',
notify_type=self._severity_to_notify_type(apprise, severity),
)
# `notify` returns True iff at least one target accepted
# the message. False means every URL endpoint rejected
# — we don't get a per-URL status code back, hence the
# opaque "Apprise rejected the notification".
if sent:
return 200, ''
return 500, 'Apprise rejected the notification (transport failure)'
except Exception as e:
return 0, str(e)
result = self._send_with_retry(_send_via_apprise)
result['channel'] = 'apprise'
return result
def test(self) -> Tuple[bool, str]:
result = self.send(
title='ProxMenux Monitor — Test',
message='Apprise channel is configured correctly. If you can read this, the URL is valid and the service accepted the notification.',
severity='INFO',
)
return bool(result.get('success')), result.get('error') or ''
# ─── Channel Factory ───────────────────────────────────────────── # ─── Channel Factory ─────────────────────────────────────────────
CHANNEL_TYPES = { CHANNEL_TYPES = {
@@ -1045,6 +1159,11 @@ CHANNEL_TYPES = {
'from_address', 'to_addresses', 'subject_prefix'], 'from_address', 'to_addresses', 'subject_prefix'],
'class': EmailChannel, 'class': EmailChannel,
}, },
'apprise': {
'name': 'Apprise',
'config_keys': ['url'],
'class': AppriseChannel,
},
} }
@@ -1052,7 +1171,7 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
"""Create a channel instance from type name and config dict. """Create a channel instance from type name and config dict.
Args: Args:
channel_type: 'telegram', 'gotify', or 'discord' channel_type: 'telegram', 'gotify', 'discord', 'email', or 'apprise'
config: Dict with channel-specific keys (see CHANNEL_TYPES) config: Dict with channel-specific keys (see CHANNEL_TYPES)
Returns: Returns:
@@ -1076,6 +1195,8 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
) )
elif channel_type == 'email': elif channel_type == 'email':
return EmailChannel(config) return EmailChannel(config)
elif channel_type == 'apprise':
return AppriseChannel(url=config.get('url', ''))
except Exception as e: except Exception as e:
print(f"[NotificationChannels] Failed to create {channel_type}: {e}") print(f"[NotificationChannels] Failed to create {channel_type}: {e}")
return None return None