feat(PROJ-1): httpOnly Cookie, Auditor-Guard, Nutzer-Aktionen (C)
Backend: - Login setzt httpOnly SameSite=Strict Cookie (archivmail_session) - Logout löscht Cookie + blacklistet Token - authMiddleware: Cookie first, Bearer als Fallback (CLI kompatibel) Frontend: - api.ts: credentials: include statt localStorage/Bearer Token - updateUser(), deleteUser() hinzugefügt - useAuth: kein localStorage mehr, nur /api/auth/me requireRole: "admin" | "auditor" | undefined - Login-Seite: kein localStorage - Navbar: kein localStorage - Admin: Nutzer-Aktionen (Sperren/Freischalten, Löschen, Passwort-Reset) Löschen verhindert wenn letzter Admin (HTTP 409) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+124
-8
@@ -5,6 +5,8 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getAuditLog,
|
||||
getSMTPStatus,
|
||||
getHealth,
|
||||
@@ -64,7 +66,7 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, loading: authLoading } = useAuth(true);
|
||||
const { user, loading: authLoading } = useAuth("admin");
|
||||
|
||||
// Dashboard state
|
||||
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
|
||||
@@ -95,6 +97,13 @@ export default function AdminPage() {
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
// User action state
|
||||
const [userActionLoading, setUserActionLoading] = useState<number | null>(null);
|
||||
const [resetPasswordUserId, setResetPasswordUserId] = useState<number | null>(null);
|
||||
const [resetPasswordValue, setResetPasswordValue] = useState("");
|
||||
const [resetPasswordError, setResetPasswordError] = useState("");
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
|
||||
// Audit state
|
||||
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||||
const [auditTotal, setAuditTotal] = useState(0);
|
||||
@@ -224,6 +233,47 @@ export default function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(u: User) {
|
||||
setUserActionLoading(u.id);
|
||||
try {
|
||||
await updateUser(u.id, { active: !u.active });
|
||||
loadUsers();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setUserActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser(u: User) {
|
||||
if (!confirm(`Benutzer "${u.username}" wirklich löschen?`)) return;
|
||||
setUserActionLoading(u.id);
|
||||
try {
|
||||
await deleteUser(u.id);
|
||||
loadUsers();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
||||
} finally {
|
||||
setUserActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!resetPasswordUserId) return;
|
||||
setResetPasswordLoading(true);
|
||||
setResetPasswordError("");
|
||||
try {
|
||||
await updateUser(resetPasswordUserId, { password: resetPasswordValue });
|
||||
setResetPasswordUserId(null);
|
||||
setResetPasswordValue("");
|
||||
} catch {
|
||||
setResetPasswordError("Passwort konnte nicht geändert werden.");
|
||||
} finally {
|
||||
setResetPasswordLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||
|
||||
if (authLoading || !user) {
|
||||
@@ -836,25 +886,54 @@ export default function AdminPage() {
|
||||
<TableHead>E-Mail</TableHead>
|
||||
<TableHead>Rolle</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.username}>
|
||||
<TableCell className="font-medium">
|
||||
{u.username}
|
||||
</TableCell>
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.username}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{u.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={u.active ? "default" : "destructive"}
|
||||
>
|
||||
<Badge variant={u.active ? "default" : "destructive"}>
|
||||
{u.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={userActionLoading === u.id}
|
||||
onClick={() => {
|
||||
setResetPasswordUserId(u.id);
|
||||
setResetPasswordValue("");
|
||||
setResetPasswordError("");
|
||||
}}
|
||||
>
|
||||
Passwort
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={userActionLoading === u.id}
|
||||
onClick={() => handleToggleActive(u)}
|
||||
>
|
||||
{userActionLoading === u.id ? "..." : u.active ? "Sperren" : "Freischalten"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={userActionLoading === u.id}
|
||||
onClick={() => handleDeleteUser(u)}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -941,6 +1020,43 @@ export default function AdminPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
|
||||
{/* Passwort-Reset Dialog */}
|
||||
<Dialog open={resetPasswordUserId !== null} onOpenChange={(open) => { if (!open) setResetPasswordUserId(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Passwort zurücksetzen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Neues Passwort für den Benutzer festlegen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleResetPassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">Neues Passwort</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={resetPasswordValue}
|
||||
onChange={(e) => setResetPasswordValue(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
/>
|
||||
</div>
|
||||
{resetPasswordError && (
|
||||
<p className="text-sm text-destructive">{resetPasswordError}</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setResetPasswordUserId(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={resetPasswordLoading}>
|
||||
{resetPasswordLoading ? "Speichern..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user