Harden auth and improve user management

This commit is contained in:
2026-03-12 20:28:06 +01:00
parent 1a8e37bd36
commit eb699666d9
26 changed files with 1105 additions and 283 deletions

View File

@@ -12,9 +12,14 @@ import PortalPage from "./pages/PortalPage";
import SearchPage from "./pages/SearchPage";
import SearchFarmerPage from "./pages/SearchFarmerPage";
import SearchCalendarPage from "./pages/SearchCalendarPage";
import UserManagementPage from "./pages/UserManagementPage";
function ProtectedRoutes() {
const { user } = useSession();
const { user, ready } = useSession();
if (!ready) {
return <div className="empty-state">Sitzung wird geladen ...</div>;
}
if (!user) {
return <Navigate to="/" replace />;
@@ -30,6 +35,7 @@ function ProtectedRoutes() {
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} />
<Route path="/admin" element={<Navigate to="/admin/landwirte" replace />} />
<Route path="/admin/benutzer" element={<UserManagementPage />} />
<Route path="/admin/landwirte" element={<AdministrationPage />} />
<Route path="/admin/medikamente" element={<AdministrationPage />} />
<Route path="/admin/erreger" element={<AdministrationPage />} />
@@ -46,7 +52,10 @@ function ProtectedRoutes() {
}
function ApplicationRouter() {
const { user } = useSession();
const { user, ready } = useSession();
if (!ready) {
return <div className="empty-state">Sitzung wird geladen ...</div>;
}
if (!user) {
return (
<Routes>

View File

@@ -23,6 +23,9 @@ function resolvePageTitle(pathname: string) {
if (pathname.startsWith("/admin/landwirte")) {
return "Verwaltung | Landwirte";
}
if (pathname.startsWith("/admin/benutzer")) {
return "Verwaltung | Benutzer";
}
if (pathname.startsWith("/admin/medikamente")) {
return "Verwaltung | Medikamente";
}
@@ -45,7 +48,7 @@ function resolvePageTitle(pathname: string) {
}
export default function AppShell() {
const { user, setUser } = useSession();
const { user, setSession } = useSession();
const location = useLocation();
const navigate = useNavigate();
@@ -76,6 +79,9 @@ export default function AppShell() {
<div className="nav-group">
<div className="nav-group__label">Verwaltung</div>
<div className="nav-subnav">
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Benutzer
</NavLink>
<NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Landwirte
</NavLink>
@@ -116,13 +122,13 @@ export default function AppShell() {
<div className="sidebar__footer">
<div className="user-chip user-chip--stacked">
<span>{user?.displayName}</span>
<small>{user?.code}</small>
<small>{user?.email ?? user?.role}</small>
</div>
<button
type="button"
className="ghost-button"
onClick={() => {
setUser(null);
setSession(null);
navigate("/");
}}
>

View File

@@ -1,4 +1,4 @@
import { USER_STORAGE_KEY } from "./storage";
import { AUTH_TOKEN_STORAGE_KEY } from "./storage";
const API_ROOT = import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? "http://localhost:8090/api" : "/api");
@@ -30,22 +30,16 @@ async function readErrorMessage(response: Response): Promise<string> {
return `Anfrage fehlgeschlagen (${response.status})`;
}
function actorHeaders(): Record<string, string> {
function authHeaders(): Record<string, string> {
if (typeof window === "undefined") {
return {};
}
const rawUser = window.localStorage.getItem(USER_STORAGE_KEY);
if (!rawUser) {
return {};
}
try {
const user = JSON.parse(rawUser) as { id?: string | null };
return user.id ? { "X-MUH-Actor-Id": user.id } : {};
} catch {
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (!token) {
return {};
}
return { Authorization: `Bearer ${token}` };
}
async function handleResponse<T>(response: Response): Promise<T> {
@@ -55,13 +49,17 @@ async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
const text = await response.text();
if (!text.trim()) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export async function apiGet<T>(path: string): Promise<T> {
return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, {
headers: actorHeaders(),
headers: authHeaders(),
}),
);
}
@@ -72,7 +70,7 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
method: "POST",
headers: {
"Content-Type": "application/json",
...actorHeaders(),
...authHeaders(),
},
body: JSON.stringify(body),
}),
@@ -85,7 +83,7 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
method: "PUT",
headers: {
"Content-Type": "application/json",
...actorHeaders(),
...authHeaders(),
},
body: JSON.stringify(body),
}),
@@ -98,7 +96,7 @@ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...actorHeaders(),
...authHeaders(),
},
body: JSON.stringify(body),
}),
@@ -109,7 +107,7 @@ export async function apiDelete(path: string): Promise<void> {
await handleResponse<void>(
await fetch(`${API_ROOT}${path}`, {
method: "DELETE",
headers: actorHeaders(),
headers: authHeaders(),
}),
);
}

View File

@@ -6,17 +6,20 @@ import {
useState,
type PropsWithChildren,
} from "react";
import { USER_STORAGE_KEY } from "./storage";
import type { UserOption } from "./types";
import { apiGet } from "./api";
import { AUTH_TOKEN_STORAGE_KEY, USER_STORAGE_KEY } from "./storage";
import type { SessionResponse, UserOption } from "./types";
interface SessionContextValue {
user: UserOption | null;
setUser: (user: UserOption | null) => void;
ready: boolean;
setSession: (session: SessionResponse | null) => void;
}
const SessionContext = createContext<SessionContextValue>({
user: null,
setUser: () => undefined,
ready: false,
setSession: () => undefined,
});
function loadStoredUser(): UserOption | null {
@@ -33,6 +36,39 @@ function loadStoredUser(): UserOption | null {
export function SessionProvider({ children }: PropsWithChildren) {
const [user, setUserState] = useState<UserOption | null>(() => loadStoredUser());
const [ready, setReady] = useState(false);
useEffect(() => {
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (!token) {
setReady(true);
return;
}
let cancelled = false;
void apiGet<UserOption>("/session/me")
.then((currentUser) => {
if (!cancelled) {
setUserState(currentUser);
}
})
.catch(() => {
if (!cancelled) {
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(USER_STORAGE_KEY);
setUserState(null);
}
})
.finally(() => {
if (!cancelled) {
setReady(true);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (user) {
@@ -42,12 +78,26 @@ export function SessionProvider({ children }: PropsWithChildren) {
window.localStorage.removeItem(USER_STORAGE_KEY);
}, [user]);
function setSession(session: SessionResponse | null) {
if (session) {
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, session.token);
setUserState(session.user);
setReady(true);
return;
}
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(USER_STORAGE_KEY);
setUserState(null);
setReady(true);
}
const value = useMemo(
() => ({
user,
setUser: setUserState,
ready,
setSession,
}),
[user],
[ready, user],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;

View File

@@ -1 +1,2 @@
export const USER_STORAGE_KEY = "muh.current-user";
export const AUTH_TOKEN_STORAGE_KEY = "muh.auth-token";

View File

@@ -45,16 +45,24 @@ export interface AntibioticOption {
export interface UserOption {
id: string;
code: string;
primaryUser: boolean;
displayName: string;
companyName: string | null;
address: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
email: string | null;
phoneNumber: string | null;
portalLogin: string | null;
role: UserRole;
}
export interface SessionResponse {
token: string;
user: UserOption;
}
export interface UserRow extends UserOption {
active: boolean;
updatedAt: string;

View File

@@ -1,7 +1,7 @@
import { FormEvent, useState } from "react";
import { apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types";
import type { SessionResponse } from "../lib/types";
type FeedbackState =
| { type: "error"; text: string }
@@ -10,7 +10,7 @@ type FeedbackState =
export default function LoginPage() {
const [showRegistration, setShowRegistration] = useState(false);
const [identifier, setIdentifier] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showLoginValidation, setShowLoginValidation] = useState(false);
const [showRegisterValidation, setShowRegisterValidation] = useState(false);
@@ -26,25 +26,25 @@ export default function LoginPage() {
passwordConfirmation: "",
});
const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setUser } = useSession();
const { setSession } = useSession();
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowLoginValidation(true);
if (!identifier.trim() || !password.trim()) {
if (!email.trim() || !password.trim()) {
setFeedback({
type: "error",
text: "Bitte E-Mail oder Benutzername und Passwort eingeben.",
text: "Bitte E-Mail und Passwort eingeben.",
});
return;
}
try {
setFeedback(null);
const response = await apiPost<UserOption>("/session/password-login", {
identifier: identifier.trim(),
const response = await apiPost<SessionResponse>("/session/password-login", {
email: email.trim(),
password,
});
setUser(response);
setSession(response);
} catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message });
}
@@ -77,12 +77,12 @@ export default function LoginPage() {
setFeedback(null);
const { passwordConfirmation, ...registrationPayload } = registration;
void passwordConfirmation;
const response = await apiPost<UserOption>("/session/register", registrationPayload);
const response = await apiPost<SessionResponse>("/session/register", registrationPayload);
setFeedback({
type: "success",
text: `Registrierung erfolgreich. Willkommen ${response.companyName ?? response.displayName}.`,
text: `Registrierung erfolgreich. Willkommen ${response.user.companyName ?? response.user.displayName}.`,
});
setUser(response);
setSession(response);
} catch (registrationError) {
setFeedback({ type: "error", text: (registrationError as Error).message });
}
@@ -105,8 +105,7 @@ export default function LoginPage() {
<p className="eyebrow">Zugang</p>
<h2>Anmelden oder registrieren</h2>
<p className="muted-text">
Anmeldung per E-Mail oder Benutzername mit Passwort sowie direkte
Kundenregistrierung.
Anmeldung per E-Mail mit Passwort sowie direkte Kundenregistrierung.
</p>
{feedback ? (
@@ -119,11 +118,12 @@ export default function LoginPage() {
{!showRegistration ? (
<form className={`login-panel__section ${showLoginValidation ? "show-validation" : ""}`} onSubmit={handlePasswordLogin}>
<label className="field field--required">
<span>E-Mail / Benutzername</span>
<span>E-Mail</span>
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder="z. B. admin oder name@hof.de"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="z. B. name@hof.de"
required
/>
</label>

View File

@@ -9,10 +9,8 @@ export default function PortalPage() {
const [selectedReports, setSelectedReports] = useState<string[]>([]);
const [message, setMessage] = useState<string | null>(null);
const [userForm, setUserForm] = useState({
code: "",
displayName: "",
email: "",
portalLogin: "",
password: "",
role: "CUSTOMER" as UserRole,
});
@@ -70,10 +68,8 @@ export default function PortalPage() {
active: true,
});
setUserForm({
code: "",
displayName: "",
email: "",
portalLogin: "",
password: "",
role: "CUSTOMER",
});
@@ -166,14 +162,6 @@ export default function PortalPage() {
<article className="section-card">
<p className="eyebrow">Benutzerverwaltung</p>
<form className={`field-grid ${showUserValidation ? "show-validation" : ""}`} onSubmit={handleCreateUser}>
<label className="field field--required">
<span>Kuerzel</span>
<input
value={userForm.code}
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))}
required
/>
</label>
<label className="field field--required">
<span>Name</span>
<input
@@ -184,15 +172,6 @@ export default function PortalPage() {
required
/>
</label>
<label className="field">
<span>Login</span>
<input
value={userForm.portalLogin}
onChange={(event) =>
setUserForm((current) => ({ ...current, portalLogin: event.target.value }))
}
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
@@ -230,10 +209,8 @@ export default function PortalPage() {
<table className="data-table">
<thead>
<tr>
<th>Kuerzel</th>
<th>Name</th>
<th>E-Mail</th>
<th>Login</th>
<th>Rolle</th>
<th>Passwort</th>
<th />
@@ -242,10 +219,8 @@ export default function PortalPage() {
<tbody>
{snapshot.users.map((user) => (
<tr key={user.id}>
<td>{user.code}</td>
<td>{user.displayName}</td>
<td>{user.email ?? "-"}</td>
<td>{user.portalLogin ?? "-"}</td>
<td>{user.role}</td>
<td>
<input

View File

@@ -99,7 +99,7 @@ export default function SampleRegistrationPage() {
sampleKind,
samplingMode,
flaggedQuarters,
userCode: user.code,
userCode: user.displayName,
userDisplayName: user.displayName,
};

View File

@@ -0,0 +1,479 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption, UserRole, 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,
};
}
export default function UserManagementPage() {
const { user } = useSession();
const [users, setUsers] = useState<UserDraft[]>([]);
const [newUser, setNewUser] = useState<UserDraft>(emptyUser());
const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
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(() => {
void loadUsers().catch((error) => setMessage((error as Error).message));
}, []);
const primaryUser = useMemo(
() => 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 {
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.");
} 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;
}
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);
}
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Verwaltung</p>
<h3>Benutzer und Stammdaten</h3>
<p className="muted-text">
Hier pflegen Sie den Hauptbenutzer Ihres Kontos und legen weitere Benutzer an.
</p>
</div>
{message ? (
<div
className={
message.includes("gespeichert") || message.includes("angelegt") || message.includes("geloescht")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
) : null}
</section>
{primaryUser ? (
<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>
</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>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th className="required-label">Name</th>
<th>E-Mail</th>
<th>Passwort</th>
<th>Aktiv</th>
<th />
</tr>
</thead>
<tbody>
{secondaryUsers.map((entry) => (
<tr key={entry.id}>
<td>
<input
required
className={showValidation && !entry.displayName.trim() ? "is-invalid" : ""}
value={entry.displayName}
onChange={(event) =>
updateExistingUser(entry.id, { displayName: event.target.value })
}
/>
</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" })
}
>
<option value="true">aktiv</option>
<option value="false">inaktiv</option>
</select>
</td>
<td className="table-actions">
<button
type="button"
className="table-link"
onClick={() => void saveUser(entry)}
>
Speichern
</button>
<button
type="button"
className="table-link table-link--danger"
onClick={() => void removeUser(entry.id)}
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}