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 { 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";
useEffect(() => {
async function loadUsers() { async function loadUsers() {
try { try {
const response = await apiGet<UserRow[]>("/portal/users"); const response = await apiGet<UserRow[]>("/portal/users");
setUsers(response.map(toDraft)); // Nur Hauptnutzer (primaryUser=true) anzeigen
setMessage(null); 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) { } catch (error) {
if (!isAdmin && user?.primaryUser && isAccessDenied(error)) { setMessage((error as Error).message);
setUsers([toDraftFromSession(user)]); } finally {
setMessage(null); setLoading(false);
return;
}
throw error;
} }
} }
useEffect(() => { void loadUsers();
void loadUsers().catch((error) => setMessage((error as Error).message));
}, []); }, []);
const primaryUser = useMemo( async function toggleUserStatus(userId: string, newStatus: boolean) {
() => users.find((entry) => entry.primaryUser) ?? null, try {
[users], const userToUpdate = users.find((u) => u.id === userId);
); if (!userToUpdate) return;
const secondaryUsers = useMemo(
() => users.filter((entry) => !entry.primaryUser), await apiPost("/portal/users", {
[users], id: userId,
); active: newStatus,
});
function updateExistingUser(userId: string, patch: Partial<UserDraft>) {
setUsers((current) => 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) { setMessage(
setShowValidation(true); `Benutzer "${userToUpdate.displayName}" wurde ${
if (!draft.displayName.trim()) { newStatus ? "freigegeben" : "gesperrt"
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(draft.primaryUser ? "Stammdaten gespeichert." : "Benutzer gespeichert.");
// 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 { // Nicht-Admin Ansicht (sollte nicht passieren, da Route geschützt ist)
await apiPost<UserRow>("/portal/users", toMutation(newUser)); if (!isAdmin) {
setNewUser(emptyUser()); return (
setMessage("Benutzer angelegt."); <div className="page-stack">
await loadUsers(); <section className="section-card">
} catch (error) { <div className="alert alert--error">
setMessage((error as Error).message); Zugriff verweigert. Diese Seite ist nur für Administratoren.
} </div>
} </section>
</div>
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);
}
} }
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>
</section>
{/* Status-Meldung */}
{message ? ( {message ? (
<div <div
className={ className={
message.includes("gespeichert") || message.includes("angelegt") || message.includes("geloescht") message.includes("freigegeben") || message.includes("gesperrt")
? "alert alert--success" ? "alert alert--success"
: "alert alert--error" : "alert alert--error"
} }
@@ -203,268 +119,60 @@ export default function UserManagementPage() {
{message} {message}
</div> </div>
) : null} ) : null}
</section>
{primaryUser ? ( {/* 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">Hauptbenutzer</p> <p className="eyebrow">Hauptnutzer</p>
<h3>Stammdaten bearbeiten</h3> <h3>Registrierte Hauptnutzer</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>
</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;