forked from jappel/leistungsbilanz-ts
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>
325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
"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>
|
|
);
|
|
}
|