Add login-required user management (admin/user roles, Config->Benutzer)
No self-signup: admins create every account in Config -> Benutzer with a typed start password; a forced password change gates first use. Session- cookie auth protects all /api/* routes; /api/users additionally requires the admin role. See docs/authentication.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b50c00c0ac
commit
c56cfb30ad
36 changed files with 4928 additions and 38 deletions
325
src/app/config/benutzer/page.tsx
Normal file
325
src/app/config/benutzer/page.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { userApi, type UserDto, type UserRole } from "../../../frontend/utils/auth-api";
|
||||
import { useAuth } from "../../../frontend/components/AuthProvider";
|
||||
import { USER_ROLES, USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
|
||||
|
||||
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
export default function BenutzerPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
||||
const [resetPasswordFor, setResetPasswordFor] = useState<UserDto | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rows = await userApi.list();
|
||||
setUsers(rows);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Benutzer konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function withBusy(userId: string, action: () => Promise<void>) {
|
||||
setBusyUserId(userId);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusyUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRole(item: UserDto, role: UserRole) {
|
||||
await withBusy(item.id, async () => {
|
||||
const updated = await userApi.update(item.id, { role });
|
||||
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleActive(item: UserDto) {
|
||||
await withBusy(item.id, async () => {
|
||||
const updated = await userApi.update(item.id, { active: !item.active });
|
||||
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: "1.5rem" }}>
|
||||
<div className="page-header" style={{ marginTop: 0 }}>
|
||||
<h2 className="h5" style={{ margin: 0 }}>
|
||||
Benutzer
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-body-secondary" style={{ marginBottom: "1rem" }}>
|
||||
Es gibt kein Self-Signup: Jeder Zugang wird hier bewusst von einem
|
||||
Administrator angelegt. Das Startpasswort wird direkt eingetragen; die
|
||||
Person muss es beim ersten Anmelden durch ein eigenes ersetzen.
|
||||
</p>
|
||||
|
||||
<NeuerBenutzerFormular
|
||||
onCreated={(created) => {
|
||||
setUsers((list) => [...list, created]);
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{loading && <p>Lädt…</p>}
|
||||
|
||||
{!loading && users.length > 0 && (
|
||||
<table className="table" style={{ marginTop: "1.5rem" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Anmeldename</th>
|
||||
<th>Rolle</th>
|
||||
<th>Aktiv</th>
|
||||
<th>Status</th>
|
||||
<th>Angelegt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((item) => {
|
||||
const isSelf = item.id === currentUser?.id;
|
||||
const busy = busyUserId === item.id;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.username}</td>
|
||||
<td>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={item.role}
|
||||
disabled={busy || isSelf}
|
||||
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
|
||||
onChange={(event) => void changeRole(item, event.target.value as UserRole)}
|
||||
>
|
||||
{USER_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{USER_ROLE_LABEL[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.active}
|
||||
disabled={busy || isSelf}
|
||||
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
|
||||
onChange={() => void toggleActive(item)}
|
||||
/>
|
||||
</td>
|
||||
<td>{item.mustChangePassword ? "Muss Passwort setzen" : "In Ordnung"}</td>
|
||||
<td>{zeitpunkt.format(new Date(item.createdAt))}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => setResetPasswordFor(item)}
|
||||
>
|
||||
Passwort zurücksetzen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!loading && users.length === 0 && <p>Noch keine Benutzer angelegt.</p>}
|
||||
|
||||
{resetPasswordFor && (
|
||||
<PasswortZuruecksetzenDialog
|
||||
item={resetPasswordFor}
|
||||
onClose={() => setResetPasswordFor(null)}
|
||||
onDone={(updated) => {
|
||||
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
||||
setResetPasswordFor(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeuerBenutzerFormular({ onCreated }: { onCreated: (created: UserDto) => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState<UserRole>("user");
|
||||
const [initialPassword, setInitialPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await userApi.create({ username, name, role, initialPassword });
|
||||
onCreated(created);
|
||||
setUsername("");
|
||||
setName("");
|
||||
setRole("user");
|
||||
setInitialPassword("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Benutzer konnte nicht angelegt werden.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="row g-2 align-items-end"
|
||||
style={{ borderBottom: "1px solid rgba(0,0,0,0.1)", paddingBottom: "1rem" }}
|
||||
>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label" htmlFor="new-user-username">
|
||||
Anmeldename
|
||||
</label>
|
||||
<input
|
||||
id="new-user-username"
|
||||
className="form-control form-control-sm"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label" htmlFor="new-user-name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="new-user-name"
|
||||
className="form-control form-control-sm"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-2">
|
||||
<label className="form-label" htmlFor="new-user-role">
|
||||
Rolle
|
||||
</label>
|
||||
<select
|
||||
id="new-user-role"
|
||||
className="form-select form-select-sm"
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as UserRole)}
|
||||
>
|
||||
{USER_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{USER_ROLE_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label" htmlFor="new-user-password">
|
||||
Startpasswort
|
||||
</label>
|
||||
<input
|
||||
id="new-user-password"
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={initialPassword}
|
||||
onChange={(event) => setInitialPassword(event.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-1">
|
||||
<button type="submit" className="btn btn-primary btn-sm w-100" disabled={submitting}>
|
||||
{submitting ? "…" : "Anlegen"}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="col-12">
|
||||
<p className="text-danger mb-0">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswortZuruecksetzenDialog({
|
||||
item,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
item: UserDto;
|
||||
onClose: () => void;
|
||||
onDone: (updated: UserDto) => void;
|
||||
}) {
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await userApi.resetPassword(item.id, newPassword);
|
||||
onDone(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Passwort konnte nicht zurückgesetzt werden.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop-inline">
|
||||
<form className="card" style={{ maxWidth: 360, padding: "1.5rem" }} onSubmit={handleSubmit}>
|
||||
<h3 className="h6">Passwort zurücksetzen für {item.name}</h3>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="reset-password">
|
||||
Neues Startpasswort
|
||||
</label>
|
||||
<input
|
||||
id="reset-password"
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
minLength={8}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
<div className="d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Speichert…" : "Zurücksetzen"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/app/config/konto/page.tsx
Normal file
103
src/app/config/konto/page.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authApi } from "../../../frontend/utils/auth-api";
|
||||
import { useAuth } from "../../../frontend/components/AuthProvider";
|
||||
import { USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
|
||||
|
||||
export default function KontoPage() {
|
||||
const { user } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordRepeat, setNewPasswordRepeat] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (newPassword !== newPasswordRepeat) {
|
||||
setError("Die beiden neuen Passwörter stimmen nicht überein.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
await authApi.changePassword(currentPassword, newPassword);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setNewPasswordRepeat("");
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ maxWidth: 480, padding: "1.5rem" }}>
|
||||
<h2 className="h5">Konto</h2>
|
||||
{user && (
|
||||
<p className="text-body-secondary">
|
||||
Angemeldet als <strong>{user.name}</strong> ({user.username}) ·{" "}
|
||||
{USER_ROLE_LABEL[user.role]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h3 className="h6 mt-4">Passwort ändern</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="konto-current-password">
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
<input
|
||||
id="konto-current-password"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="konto-new-password">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
id="konto-new-password"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="konto-new-password-repeat">
|
||||
Neues Passwort wiederholen
|
||||
</label>
|
||||
<input
|
||||
id="konto-new-password-repeat"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={newPasswordRepeat}
|
||||
onChange={(event) => setNewPasswordRepeat(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{success && <p className="text-success">Passwort wurde geändert.</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Speichert…" : "Passwort ändern"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/app/config/layout.tsx
Normal file
51
src/app/config/layout.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "../../frontend/components/AuthProvider";
|
||||
|
||||
const CONFIG_TABS = [
|
||||
{ href: "/config/benutzer", label: "Benutzer" },
|
||||
{ href: "/config/konto", label: "Konto" },
|
||||
] as const;
|
||||
|
||||
export default function ConfigLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { status, isAdmin } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// LoginGate already guarantees a signed-in user with a set password by
|
||||
// the time this layout renders; only the role still needs checking.
|
||||
if (status === "signed-in" && !isAdmin) {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [status, isAdmin, router]);
|
||||
|
||||
if (status !== "signed-in" || !isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-4">
|
||||
<div className="page-header">
|
||||
<h1>Config</h1>
|
||||
</div>
|
||||
|
||||
<nav className="config-tabs">
|
||||
{CONFIG_TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={pathname?.startsWith(tab.href) ? "config-tab active" : "config-tab"}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/config/page.tsx
Normal file
5
src/app/config/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ConfigPage() {
|
||||
redirect("/config/benutzer");
|
||||
}
|
||||
|
|
@ -2716,3 +2716,116 @@ button.structure-entry-main:focus-visible {
|
|||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Anmeldung (Sidebar-Config-Link, Identität, Login-Gate) ─────────── */
|
||||
|
||||
.sidebar-nav-bottom {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.sidebar-identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 8px 14px 10px;
|
||||
}
|
||||
|
||||
.sidebar-identity-label {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(199, 214, 224, 0.7);
|
||||
}
|
||||
|
||||
.sidebar-identity-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
color: #c7d6e0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-logout:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-identity {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-gate-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-md);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--color-primary-deep) 0%,
|
||||
var(--color-primary-dark) 45%,
|
||||
var(--color-primary) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.login-gate-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 28px 26px;
|
||||
}
|
||||
|
||||
.config-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.config-tab {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.config-tab:hover {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.config-tab.active {
|
||||
opacity: 1;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.modal-backdrop-inline {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import Script from "next/script";
|
|||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./globals.css";
|
||||
import { AppShell } from "../frontend/components/AppShell";
|
||||
import { AuthProvider } from "../frontend/components/AuthProvider";
|
||||
import { LoginGate } from "../frontend/components/LoginGate";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Leistungsbilanz",
|
||||
|
|
@ -36,7 +38,11 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
|||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{THEME_INIT_SCRIPT}
|
||||
</Script>
|
||||
<AppShell>{children}</AppShell>
|
||||
<AuthProvider>
|
||||
<LoginGate>
|
||||
<AppShell>{children}</AppShell>
|
||||
</LoginGate>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue