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

@@ -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>
);
}