UserManagementPage für Admin vereinfacht: Nur Hauptnutzer anzeigen mit Freigabe/Sperre-Funktion

This commit is contained in:
2026-03-16 17:02:19 +01:00
parent 2f9b12250f
commit 021730b90b
2 changed files with 185 additions and 410 deletions

View File

@@ -1,201 +1,117 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPost } from "../lib/api";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption, UserRole, UserRow } from "../lib/types";
import type { UserRow } from "../lib/types";
type UserDraft = UserRow & {
password: string;
passwordRepeat: string;
};
function toDraft(user: UserRow): UserDraft {
return {
...user,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
};
}
function emptyUser(): UserDraft {
return {
id: "",
primaryUser: false,
displayName: "",
companyName: "",
address: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
password: "",
passwordRepeat: "",
active: true,
role: "CUSTOMER",
updatedAt: new Date().toISOString(),
};
}
function toDraftFromSession(user: UserOption): UserDraft {
return {
id: user.id,
primaryUser: user.primaryUser,
displayName: user.displayName,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
active: true,
role: user.role,
updatedAt: new Date().toISOString(),
};
}
function isAccessDenied(error: unknown): boolean {
return error instanceof Error && error.message.trim().toLowerCase() === "access denied";
}
function toMutation(user: UserDraft) {
return {
id: user.id || null,
displayName: user.displayName,
companyName: user.companyName || null,
address: user.address || null,
street: user.street || null,
houseNumber: user.houseNumber || null,
postalCode: user.postalCode || null,
city: user.city || null,
email: user.email || null,
phoneNumber: user.phoneNumber || null,
password: user.password || null,
active: user.active,
role: user.role,
};
interface PrimaryUserRow {
id: string;
displayName: string;
companyName: string | null;
email: string | null;
active: boolean;
updatedAt: string;
}
export default function UserManagementPage() {
const { user } = useSession();
const [users, setUsers] = useState<UserDraft[]>([]);
const [newUser, setNewUser] = useState<UserDraft>(emptyUser());
const [users, setUsers] = useState<PrimaryUserRow[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
const isAdmin = user?.role === "ADMIN";
useEffect(() => {
async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
setUsers(response.map(toDraft));
setMessage(null);
// Nur Hauptnutzer (primaryUser=true) anzeigen
const primaryUsers = response
.filter((u) => u.primaryUser)
.map((u) => ({
id: u.id,
displayName: u.displayName,
companyName: u.companyName,
email: u.email,
active: u.active,
updatedAt: u.updatedAt,
}));
setUsers(primaryUsers);
} catch (error) {
if (!isAdmin && user?.primaryUser && isAccessDenied(error)) {
setUsers([toDraftFromSession(user)]);
setMessage(null);
return;
}
throw error;
setMessage((error as Error).message);
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadUsers().catch((error) => setMessage((error as Error).message));
void loadUsers();
}, []);
const primaryUser = useMemo(
() => users.find((entry) => entry.primaryUser) ?? null,
[users],
);
const secondaryUsers = useMemo(
() => users.filter((entry) => !entry.primaryUser),
[users],
);
async function toggleUserStatus(userId: string, newStatus: boolean) {
try {
const userToUpdate = users.find((u) => u.id === userId);
if (!userToUpdate) return;
await apiPost("/portal/users", {
id: userId,
active: newStatus,
});
function updateExistingUser(userId: string, patch: Partial<UserDraft>) {
setUsers((current) =>
current.map((entry) => (entry.id === userId ? { ...entry, ...patch } : entry)),
current.map((u) => (u.id === userId ? { ...u, active: newStatus } : u))
);
}
async function saveUser(draft: UserDraft) {
setShowValidation(true);
if (!draft.displayName.trim()) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
try {
const saved = await apiPost<UserRow>("/portal/users", toMutation(draft));
setUsers((current) =>
current.map((entry) => (entry.id === draft.id ? toDraft(saved) : entry)),
setMessage(
`Benutzer "${userToUpdate.displayName}" wurde ${
newStatus ? "freigegeben" : "gesperrt"
}.`
);
setMessage(draft.primaryUser ? "Stammdaten gespeichert." : "Benutzer gespeichert.");
// Nachricht nach 3 Sekunden ausblenden
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
}
}
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowValidation(true);
if (!newUser.displayName.trim() || !(newUser.email ?? "").trim() || !newUser.password.trim()) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
if (newUser.password !== newUser.passwordRepeat) {
setMessage("Die Passwoerter stimmen nicht ueberein.");
return;
// Formatierungsfunktion für das Datum
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
try {
await apiPost<UserRow>("/portal/users", toMutation(newUser));
setNewUser(emptyUser());
setMessage("Benutzer angelegt.");
await loadUsers();
} catch (error) {
setMessage((error as Error).message);
}
}
async function removeUser(userId: string) {
try {
await apiDelete(`/portal/users/${userId}`);
setUsers((current) => current.filter((entry) => entry.id !== userId));
setMessage("Benutzer geloescht.");
void loadUsers().catch(() => undefined);
} catch (error) {
setMessage((error as Error).message);
}
// Nicht-Admin Ansicht (sollte nicht passieren, da Route geschützt ist)
if (!isAdmin) {
return (
<div className="page-stack">
<section className="section-card">
<div className="alert alert--error">
Zugriff verweigert. Diese Seite ist nur für Administratoren.
</div>
</section>
</div>
);
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
{/* Header */}
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Verwaltung</p>
<h3>Benutzer und Stammdaten</h3>
<p className="eyebrow">Benutzerverwaltung</p>
<h3>Hauptnutzer freigeben oder sperren</h3>
<p className="muted-text">
Hier pflegen Sie den Hauptbenutzer Ihres Kontos und legen weitere Benutzer an.
Verwalten Sie den Zugriff von Hauptnutzern auf das System.
Gesperrte Benutzer können sich nicht mehr anmelden.
</p>
</div>
</section>
{/* Status-Meldung */}
{message ? (
<div
className={
message.includes("gespeichert") || message.includes("angelegt") || message.includes("geloescht")
message.includes("freigegeben") || message.includes("gesperrt")
? "alert alert--success"
: "alert alert--error"
}
@@ -203,268 +119,60 @@ export default function UserManagementPage() {
{message}
</div>
) : null}
</section>
{primaryUser ? (
{/* Tabelle mit Hauptnutzern */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Hauptbenutzer</p>
<h3>Stammdaten bearbeiten</h3>
</div>
<div className="info-chip">{primaryUser.email ?? primaryUser.displayName}</div>
</div>
<div className={`field-grid ${showValidation ? "show-validation" : ""}`}>
<label className="field field--required">
<span>Name</span>
<input
required
value={primaryUser.displayName}
onChange={(event) =>
updateExistingUser(primaryUser.id, { displayName: event.target.value })
}
/>
</label>
<label className="field">
<span>Firmenname</span>
<input
value={primaryUser.companyName ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { companyName: event.target.value })
}
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={primaryUser.email ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { email: event.target.value })}
/>
</label>
<label className="field">
<span>Strasse</span>
<input
value={primaryUser.street ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { street: event.target.value })}
/>
</label>
<label className="field">
<span>Hausnummer</span>
<input
value={primaryUser.houseNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { houseNumber: event.target.value })
}
/>
</label>
<label className="field">
<span>PLZ</span>
<input
value={primaryUser.postalCode ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { postalCode: event.target.value })
}
/>
</label>
<label className="field">
<span>Ort</span>
<input
value={primaryUser.city ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { city: event.target.value })}
/>
</label>
<label className="field">
<span>Telefonnummer</span>
<input
value={primaryUser.phoneNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { phoneNumber: event.target.value })
}
/>
</label>
<label className="field field--wide">
<span>Neues Passwort</span>
<input
type="password"
value={primaryUser.password}
onChange={(event) => updateExistingUser(primaryUser.id, { password: event.target.value })}
/>
</label>
</div>
<div className="page-actions">
<button type="button" className="accent-button" onClick={() => void saveUser(primaryUser)}>
Stammdaten speichern
</button>
</div>
</section>
) : null}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Benutzer</p>
<h3>Benutzer anlegen</h3>
<p className="eyebrow">Hauptnutzer</p>
<h3>Registrierte Hauptnutzer</h3>
</div>
</div>
<form
className={`field-grid ${showValidation ? "show-validation" : ""}`}
onSubmit={createUser}
autoComplete="off"
>
<label className="field field--required">
<span>Name</span>
<input
required
value={newUser.displayName}
onChange={(event) =>
setNewUser((current) => ({ ...current, displayName: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>E-Mail</span>
<input
required
type="email"
value={newUser.email ?? ""}
autoComplete="off"
onChange={(event) => setNewUser((current) => ({ ...current, email: event.target.value }))}
/>
</label>
<label className="field field--required">
<span>Passwort</span>
<input
required
type="password"
autoComplete="new-password"
value={newUser.password}
onChange={(event) =>
setNewUser((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>Passwort wiederholen</span>
<input
required
type="password"
autoComplete="new-password"
className={
showValidation && newUser.password !== newUser.passwordRepeat ? "is-invalid" : ""
}
value={newUser.passwordRepeat}
onChange={(event) =>
setNewUser((current) => ({ ...current, passwordRepeat: event.target.value }))
}
/>
</label>
{isAdmin ? (
<label className="field">
<span>Rolle</span>
<select
value={newUser.role}
onChange={(event) =>
setNewUser((current) => ({
...current,
role: event.target.value as UserRole,
}))
}
>
<option value="CUSTOMER">CUSTOMER</option>
<option value="ADMIN">ADMIN</option>
</select>
</label>
) : null}
<div className="page-actions">
<button type="submit" className="accent-button">
Benutzer anlegen
</button>
</div>
</form>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Benutzerliste</p>
<h3>Bereits angelegte Benutzer</h3>
</div>
</div>
{secondaryUsers.length === 0 ? (
<div className="empty-state">Noch keine weiteren Benutzer vorhanden.</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen...</div>
) : users.length === 0 ? (
<div className="empty-state">Keine Hauptnutzer vorhanden.</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th className="required-label">Name</th>
<th>Name</th>
<th>Firma</th>
<th>E-Mail</th>
<th>Passwort</th>
<th>Aktiv</th>
<th />
<th>Status</th>
<th>Letzte Änderung</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{secondaryUsers.map((entry) => (
<tr key={entry.id}>
{users.map((entry) => (
<tr key={entry.id} className={!entry.active ? "table-row--inactive" : ""}>
<td>
<input
required
className={showValidation && !entry.displayName.trim() ? "is-invalid" : ""}
value={entry.displayName}
onChange={(event) =>
updateExistingUser(entry.id, { displayName: event.target.value })
}
/>
<strong>{entry.displayName}</strong>
</td>
<td>{entry.companyName ?? "-"}</td>
<td>{entry.email ?? "-"}</td>
<td>
<input
type="email"
value={entry.email ?? ""}
onChange={(event) => updateExistingUser(entry.id, { email: event.target.value })}
/>
</td>
<td>
<input
type="password"
value={entry.password}
onChange={(event) =>
updateExistingUser(entry.id, { password: event.target.value })
}
placeholder="Neues Passwort"
/>
</td>
<td>
<select
value={entry.active ? "true" : "false"}
onChange={(event) =>
updateExistingUser(entry.id, { active: event.target.value === "true" })
}
<span
className={`status-pill ${
entry.active ? "status-pill--active" : "status-pill--inactive"
}`}
>
<option value="true">aktiv</option>
<option value="false">inaktiv</option>
</select>
{entry.active ? "Freigegeben" : "Gesperrt"}
</span>
</td>
<td className="table-actions">
<td className="text-muted">{formatDate(entry.updatedAt)}</td>
<td>
<button
type="button"
className="table-link"
onClick={() => void saveUser(entry)}
className={`action-button ${
entry.active ? "action-button--danger" : "action-button--success"
}`}
onClick={() => toggleUserStatus(entry.id, !entry.active)}
>
Speichern
</button>
<button
type="button"
className="table-link table-link--danger"
onClick={() => void removeUser(entry.id)}
>
Loeschen
{entry.active ? "Sperren" : "Freigeben"}
</button>
</td>
</tr>
@@ -474,6 +182,18 @@ export default function UserManagementPage() {
</div>
)}
</section>
{/* Info-Box */}
<section className="section-card">
<div className="info-panel">
<strong>Hinweis</strong>
<p>
Hauptnutzer sind die primären Kontoinhaber. Wenn Sie einen Hauptnutzer sperren,
können sich dieser und alle zugehörigen Nebennutzer nicht mehr anmelden.
Die Daten bleiben erhalten und können durch Freigabe wieder aktiviert werden.
</p>
</div>
</section>
</div>
);
}

View File

@@ -1244,6 +1244,61 @@ a {
font-size: 1.25rem;
}
/* User Management Table Styles */
.table-row--inactive {
background-color: rgba(157, 60, 48, 0.05);
}
.status-pill--active {
background-color: rgba(74, 124, 89, 0.15);
color: #4a7c59;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-pill--inactive {
background-color: rgba(157, 60, 48, 0.15);
color: #9d3c30;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.action-button {
padding: 8px 16px;
border-radius: 8px;
border: none;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button--success {
background-color: rgba(74, 124, 89, 0.15);
color: #4a7c59;
}
.action-button--success:hover {
background-color: rgba(74, 124, 89, 0.25);
}
.action-button--danger {
background-color: rgba(157, 60, 48, 0.15);
color: #9d3c30;
}
.action-button--danger:hover {
background-color: rgba(157, 60, 48, 0.25);
}
.text-muted {
color: var(--muted);
}
.dialog-backdrop {
position: fixed;
inset: 0;