Harden auth and improve user management
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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("/");
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const USER_STORAGE_KEY = "muh.current-user";
|
||||
export const AUTH_TOKEN_STORAGE_KEY = "muh.auth-token";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function SampleRegistrationPage() {
|
||||
sampleKind,
|
||||
samplingMode,
|
||||
flaggedQuarters,
|
||||
userCode: user.code,
|
||||
userCode: user.displayName,
|
||||
userDisplayName: user.displayName,
|
||||
};
|
||||
|
||||
|
||||
479
frontend/src/pages/UserManagementPage.tsx
Normal file
479
frontend/src/pages/UserManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user