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:
sysops
2026-03-15 19:57:13 +01:00
parent a94b1d3e52
commit 7e165c8eed
6 changed files with 213 additions and 62 deletions
+124 -8
View File
@@ -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>
);
}
+5 -6
View File
@@ -16,10 +16,10 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false);
useEffect(() => {
const token = localStorage.getItem("archivmail_token");
if (token) {
router.replace("/search");
}
// Check if already logged in via session cookie
import("@/lib/api").then(({ getMe }) =>
getMe().then(() => router.replace("/search")).catch(() => {})
);
}, [router]);
async function handleSubmit(e: React.FormEvent) {
@@ -28,8 +28,7 @@ export default function LoginPage() {
setLoading(true);
try {
const res = await login(username, password);
localStorage.setItem("archivmail_token", res.token);
await login(username, password);
router.push("/search");
} catch {
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.");