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,470 +1,178 @@
import { FormEvent, useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { apiDelete, apiGet, apiPost } from "../lib/api"; import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session"; import { useSession } from "../lib/session";
import type { UserOption, UserRole, UserRow } from "../lib/types"; import type { UserRow } from "../lib/types";
type UserDraft = UserRow & { interface PrimaryUserRow {
password: string; id: string;
passwordRepeat: string; displayName: string;
}; companyName: string | null;
email: string | null;
function toDraft(user: UserRow): UserDraft { active: boolean;
return { updatedAt: string;
...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,
};
} }
export default function UserManagementPage() { export default function UserManagementPage() {
const { user } = useSession(); const { user } = useSession();
const [users, setUsers] = useState<UserDraft[]>([]); const [users, setUsers] = useState<PrimaryUserRow[]>([]);
const [newUser, setNewUser] = useState<UserDraft>(emptyUser()); const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
const isAdmin = user?.role === "ADMIN"; const isAdmin = user?.role === "ADMIN";
async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
setUsers(response.map(toDraft));
setMessage(null);
} catch (error) {
if (!isAdmin && user?.primaryUser && isAccessDenied(error)) {
setUsers([toDraftFromSession(user)]);
setMessage(null);
return;
}
throw error;
}
}
useEffect(() => { useEffect(() => {
void loadUsers().catch((error) => setMessage((error as Error).message)); async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
// 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) {
setMessage((error as Error).message);
} finally {
setLoading(false);
}
}
void loadUsers();
}, []); }, []);
const primaryUser = useMemo( async function toggleUserStatus(userId: string, newStatus: boolean) {
() => users.find((entry) => entry.primaryUser) ?? null,
[users],
);
const secondaryUsers = useMemo(
() => users.filter((entry) => !entry.primaryUser),
[users],
);
function updateExistingUser(userId: string, patch: Partial<UserDraft>) {
setUsers((current) =>
current.map((entry) => (entry.id === userId ? { ...entry, ...patch } : entry)),
);
}
async function saveUser(draft: UserDraft) {
setShowValidation(true);
if (!draft.displayName.trim()) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
try { try {
const saved = await apiPost<UserRow>("/portal/users", toMutation(draft)); const userToUpdate = users.find((u) => u.id === userId);
if (!userToUpdate) return;
await apiPost("/portal/users", {
id: userId,
active: newStatus,
});
setUsers((current) => setUsers((current) =>
current.map((entry) => (entry.id === draft.id ? toDraft(saved) : entry)), current.map((u) => (u.id === userId ? { ...u, active: newStatus } : u))
); );
setMessage(draft.primaryUser ? "Stammdaten gespeichert." : "Benutzer gespeichert.");
setMessage(
`Benutzer "${userToUpdate.displayName}" wurde ${
newStatus ? "freigegeben" : "gesperrt"
}.`
);
// Nachricht nach 3 Sekunden ausblenden
setTimeout(() => setMessage(null), 3000);
} catch (error) { } catch (error) {
setMessage((error as Error).message); setMessage((error as Error).message);
} }
} }
async function createUser(event: FormEvent<HTMLFormElement>) { // Formatierungsfunktion für das Datum
event.preventDefault(); function formatDate(value: string) {
setShowValidation(true); return new Intl.DateTimeFormat("de-DE", {
if (!newUser.displayName.trim() || !(newUser.email ?? "").trim() || !newUser.password.trim()) { dateStyle: "medium",
setMessage("Bitte alle Pflichtfelder ausfuellen."); timeStyle: "short",
return; }).format(new Date(value));
}
if (newUser.password !== newUser.passwordRepeat) {
setMessage("Die Passwoerter stimmen nicht ueberein.");
return;
}
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) { // Nicht-Admin Ansicht (sollte nicht passieren, da Route geschützt ist)
try { if (!isAdmin) {
await apiDelete(`/portal/users/${userId}`); return (
setUsers((current) => current.filter((entry) => entry.id !== userId)); <div className="page-stack">
setMessage("Benutzer geloescht."); <section className="section-card">
void loadUsers().catch(() => undefined); <div className="alert alert--error">
} catch (error) { Zugriff verweigert. Diese Seite ist nur für Administratoren.
setMessage((error as Error).message); </div>
} </section>
</div>
);
} }
return ( return (
<div className="page-stack"> <div className="page-stack">
<section className="section-card section-card--hero"> {/* Header */}
<section className="hero-card admin-hero">
<div> <div>
<p className="eyebrow">Verwaltung</p> <p className="eyebrow">Benutzerverwaltung</p>
<h3>Benutzer und Stammdaten</h3> <h3>Hauptnutzer freigeben oder sperren</h3>
<p className="muted-text"> <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> </p>
</div> </div>
{message ? (
<div
className={
message.includes("gespeichert") || message.includes("angelegt") || message.includes("geloescht")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
) : null}
</section> </section>
{primaryUser ? ( {/* Status-Meldung */}
<section className="section-card"> {message ? (
<div className="section-card__header"> <div
<div> className={
<p className="eyebrow">Hauptbenutzer</p> message.includes("freigegeben") || message.includes("gesperrt")
<h3>Stammdaten bearbeiten</h3> ? "alert alert--success"
</div> : "alert alert--error"
<div className="info-chip">{primaryUser.email ?? primaryUser.displayName}</div> }
</div> >
{message}
<div className={`field-grid ${showValidation ? "show-validation" : ""}`}> </div>
<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} ) : null}
{/* Tabelle mit Hauptnutzern */}
<section className="section-card"> <section className="section-card">
<div className="section-card__header"> <div className="section-card__header">
<div> <div>
<p className="eyebrow">Benutzer</p> <p className="eyebrow">Hauptnutzer</p>
<h3>Benutzer anlegen</h3> <h3>Registrierte Hauptnutzer</h3>
</div> </div>
</div> </div>
<form {loading ? (
className={`field-grid ${showValidation ? "show-validation" : ""}`} <div className="empty-state">Benutzer werden geladen...</div>
onSubmit={createUser} ) : users.length === 0 ? (
autoComplete="off" <div className="empty-state">Keine Hauptnutzer vorhanden.</div>
>
<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>
) : ( ) : (
<div className="table-shell"> <div className="table-shell">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th className="required-label">Name</th> <th>Name</th>
<th>Firma</th>
<th>E-Mail</th> <th>E-Mail</th>
<th>Passwort</th> <th>Status</th>
<th>Aktiv</th> <th>Letzte Änderung</th>
<th /> <th>Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{secondaryUsers.map((entry) => ( {users.map((entry) => (
<tr key={entry.id}> <tr key={entry.id} className={!entry.active ? "table-row--inactive" : ""}>
<td> <td>
<input <strong>{entry.displayName}</strong>
required
className={showValidation && !entry.displayName.trim() ? "is-invalid" : ""}
value={entry.displayName}
onChange={(event) =>
updateExistingUser(entry.id, { displayName: event.target.value })
}
/>
</td> </td>
<td>{entry.companyName ?? "-"}</td>
<td>{entry.email ?? "-"}</td>
<td> <td>
<input <span
type="email" className={`status-pill ${
value={entry.email ?? ""} entry.active ? "status-pill--active" : "status-pill--inactive"
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" })
}
> >
<option value="true">aktiv</option> {entry.active ? "Freigegeben" : "Gesperrt"}
<option value="false">inaktiv</option> </span>
</select>
</td> </td>
<td className="table-actions"> <td className="text-muted">{formatDate(entry.updatedAt)}</td>
<td>
<button <button
type="button" type="button"
className="table-link" className={`action-button ${
onClick={() => void saveUser(entry)} entry.active ? "action-button--danger" : "action-button--success"
}`}
onClick={() => toggleUserStatus(entry.id, !entry.active)}
> >
Speichern {entry.active ? "Sperren" : "Freigeben"}
</button>
<button
type="button"
className="table-link table-link--danger"
onClick={() => void removeUser(entry.id)}
>
Loeschen
</button> </button>
</td> </td>
</tr> </tr>
@@ -474,6 +182,18 @@ export default function UserManagementPage() {
</div> </div>
)} )}
</section> </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> </div>
); );
} }

View File

@@ -1244,6 +1244,61 @@ a {
font-size: 1.25rem; 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 { .dialog-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;