feat(users): add user administration and password management

This commit is contained in:
Schubert Ferenc 2026-07-11 14:04:30 +02:00
parent 47f54d3461
commit b584e60273
16 changed files with 720 additions and 22 deletions

View file

@ -0,0 +1,63 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useAuth } from "@/components/auth";
import { apiFetch } from "@/lib/api";
const schema = z.object({
current_password: z.string().min(1, "Aktuelles Passwort erforderlich"),
new_password: z.string().min(12, "Mindestens 12 Zeichen"),
new_password_confirmation: z.string().min(12, "Mindestens 12 Zeichen")
}).refine((values) => values.new_password === values.new_password_confirmation, {
path: ["new_password_confirmation"],
message: "Die neuen Passwoerter stimmen nicht ueberein"
});
type SecurityForm = z.infer<typeof schema>;
export default function SecurityPage() {
const { token, refreshUser } = useAuth();
const [message, setMessage] = useState("");
const form = useForm<SecurityForm>({
resolver: zodResolver(schema),
defaultValues: { current_password: "", new_password: "", new_password_confirmation: "" }
});
async function onSubmit(values: SecurityForm) {
await apiFetch("/auth/change-password", token ?? "", { method: "POST", body: JSON.stringify(values) });
setMessage("Passwort erfolgreich geaendert");
form.reset();
refreshUser();
}
return (
<div className="mx-auto max-w-xl">
<header className="mb-6">
<h1 className="text-3xl font-semibold text-text">Sicherheit</h1>
<p className="mt-2 text-text-light">Hier aenderst du dein Passwort.</p>
</header>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 rounded-lg border border-border bg-surface p-6 shadow-soft">
<label className="block">
<span className="text-sm font-medium">Aktuelles Passwort</span>
<input type="password" {...form.register("current_password")} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
</label>
<label className="block">
<span className="text-sm font-medium">Neues Passwort</span>
<input type="password" {...form.register("new_password")} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
</label>
<label className="block">
<span className="text-sm font-medium">Neues Passwort wiederholen</span>
<input type="password" {...form.register("new_password_confirmation")} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
</label>
{message && <div className="rounded-lg border border-success/30 bg-white p-3 text-sm text-success shadow-soft">{message}</div>}
<div className="flex justify-end">
<button type="submit" className="btn btn-primary h-12"><KeyRound className="h-4 w-4" /> Passwort ändern</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,251 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, KeyRound, RotateCcw, Search, UserPlus2 } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useAuth } from "@/components/auth";
import { apiDelete, apiFetch, apiGet, Paginated, User } from "@/lib/api";
const schema = z.object({
first_name: z.string().min(1, "Vorname erforderlich"),
last_name: z.string().min(1, "Nachname erforderlich"),
email: z.string().email("Gueltige E-Mail erforderlich"),
role: z.enum(["admin", "pruefer", "mitarbeiter", "leser"]),
is_active: z.boolean().default(true),
must_change_password: z.boolean().default(true),
temporary_password: z.string().min(12, "Mindestens 12 Zeichen")
});
type UserForm = z.infer<typeof schema>;
const roleLabels: Record<User["role"], string> = {
admin: "ADMIN",
pruefer: "PRUEFER",
mitarbeiter: "MITARBEITER",
leser: "LESER"
};
export default function UsersPage() {
const { token, user } = useAuth();
const client = useQueryClient();
const [search, setSearch] = useState("");
const [role, setRole] = useState("");
const [active, setActive] = useState("");
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [editing, setEditing] = useState<User | null>(null);
const [temporaryPassword, setTemporaryPassword] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const form = useForm<UserForm>({
resolver: zodResolver(schema),
defaultValues: {
first_name: "",
last_name: "",
email: "",
role: "mitarbeiter",
is_active: true,
must_change_password: true,
temporary_password: ""
}
});
const queryKey = useMemo(
() => ["users", token, page, search, role, active, sortBy, sortOrder],
[token, page, search, role, active, sortBy, sortOrder]
);
const users = useQuery({
queryKey,
queryFn: () =>
apiGet<Paginated<User>>(
`/users?page=${page}&page_size=10&search=${encodeURIComponent(search)}&role=${encodeURIComponent(role)}&active=${encodeURIComponent(active)}&sort_by=${encodeURIComponent(sortBy)}&sort_order=${sortOrder}`,
token ?? ""
),
enabled: Boolean(token)
});
const saveMutation = useMutation({
mutationFn: async (values: UserForm) => {
const payload = {
...values,
email: values.email.toLowerCase()
};
if (editing) {
return apiFetch<User>(`/users/${editing.id}`, token ?? "", { method: "PUT", body: JSON.stringify(payload) });
}
return apiFetch<{ temporary_password: string; user: User }>(`/users`, token ?? "", {
method: "POST",
body: JSON.stringify(payload)
});
},
onSuccess: async (created) => {
if (!editing) {
setTemporaryPassword((created as { temporary_password?: string }).temporary_password ?? "");
}
setMessage(editing ? "Benutzer gespeichert" : "Benutzer angelegt");
setError("");
setEditing(null);
form.reset();
await client.invalidateQueries({ queryKey: ["users"] });
},
onError: (err: Error) => setError(err.message)
});
const deactivateMutation = useMutation({
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/deactivate`, token ?? "", { method: "POST" }),
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] })
});
const activateMutation = useMutation({
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/activate`, token ?? "", { method: "POST" }),
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] })
});
const resetMutation = useMutation({
mutationFn: (item: User) => apiFetch<{ temporary_password: string }>(`/users/${item.id}/reset-password`, token ?? "", { method: "POST" }),
onSuccess: (result) => {
setTemporaryPassword(result.temporary_password);
setMessage("Temporäres Passwort erzeugt");
}
});
if (user?.role !== "admin") {
return <div className="rounded-lg border border-border bg-surface p-6 text-text-light">Nur fuer Administratoren verfuegbar.</div>;
}
function startCreate() {
setEditing(null);
form.reset({ first_name: "", last_name: "", email: "", role: "mitarbeiter", is_active: true, must_change_password: true, temporary_password: "" });
}
function startEdit(item: User) {
setEditing(item);
form.reset({
first_name: item.first_name,
last_name: item.last_name,
email: item.email,
role: item.role,
is_active: item.is_active,
must_change_password: item.must_change_password,
temporary_password: ""
});
}
return (
<div className="space-y-6">
<header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-semibold text-text">Benutzer</h1>
<p className="mt-2 text-text-light">Verwaltung von Rollen, Aktivstatus und Passworten.</p>
</div>
<button type="button" className="btn btn-primary h-12" onClick={startCreate}><UserPlus2 className="h-4 w-4" /> Benutzer anlegen</button>
</header>
<section className="rounded-lg border border-border bg-surface p-4 shadow-soft">
<div className="grid gap-3 md:grid-cols-4">
<label className="flex h-12 items-center gap-2 rounded-lg border border-border px-3 md:col-span-2">
<Search className="h-4 w-4 text-primary" />
<input value={search} onChange={(e) => setSearch(e.target.value)} className="flex-1 outline-none" placeholder="Suchen" />
</label>
<select value={role} onChange={(e) => setRole(e.target.value)} className="h-12 rounded-lg border border-border px-3">
<option value="">Alle Rollen</option>
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<select value={active} onChange={(e) => setActive(e.target.value)} className="h-12 rounded-lg border border-border px-3">
<option value="">Alle Status</option>
<option value="true">Aktiv</option>
<option value="false">Inaktiv</option>
</select>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="h-12 rounded-lg border border-border px-3">
<option value="created_at">Erstellt am</option>
<option value="name">Name</option>
<option value="email">E-Mail</option>
<option value="role">Rolle</option>
<option value="last_login_at">Letzter Login</option>
<option value="password_changed_at">Passwort geaendert</option>
</select>
<button type="button" className="btn btn-secondary h-12" onClick={() => setSortOrder((value) => (value === "asc" ? "desc" : "asc"))}>
Sortierung: {sortOrder.toUpperCase()}
</button>
</div>
</section>
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="bg-background text-xs uppercase text-text-light">
<tr>
<th className="px-5 py-4">Name</th>
<th className="px-5 py-4">E-Mail</th>
<th className="px-5 py-4">Rolle</th>
<th className="px-5 py-4">Status</th>
<th className="px-5 py-4">Letzter Login</th>
<th className="px-5 py-4">Passwort zuletzt geaendert</th>
<th className="px-5 py-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{(users.data?.items ?? []).map((item) => (
<tr key={item.id}>
<td className="px-5 py-4">{item.first_name} {item.last_name}</td>
<td className="px-5 py-4">{item.email}</td>
<td className="px-5 py-4">{roleLabels[item.role]}</td>
<td className="px-5 py-4">{item.is_active ? "Aktiv" : "Inaktiv"}</td>
<td className="px-5 py-4">{item.last_login_at ?? "nicht erfasst"}</td>
<td className="px-5 py-4">{item.password_changed_at ?? "nicht erfasst"}</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => startEdit(item)}>Bearbeiten</button>
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => resetMutation.mutate(item)}>{resetMutation.isPending ? <span className="spinner" /> : <RotateCcw className="h-4 w-4" />} Temporäres Passwort setzen</button>
{item.is_active ? (
<button type="button" className="btn btn-danger h-10 px-3" onClick={() => deactivateMutation.mutate(item)}>Deaktivieren</button>
) : (
<button type="button" className="btn btn-success h-10 px-3" onClick={() => activateMutation.mutate(item)}><Check className="h-4 w-4" /> Reaktivieren</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<footer className="flex items-center justify-between text-sm text-text-light">
<span>{users.data?.total ?? 0} Datensätze</span>
<div className="flex items-center gap-3">
<button type="button" className="btn btn-secondary" disabled={page <= 1} onClick={() => setPage((v) => v - 1)}>Zurück</button>
<span>Seite {page}</span>
<button type="button" className="btn btn-secondary" disabled={(users.data?.items.length ?? 0) < 10} onClick={() => setPage((v) => v + 1)}>Weiter</button>
</div>
</footer>
{temporaryPassword && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 shadow-soft"><p className="text-sm text-text-light">Temporäres Passwort</p><p className="mt-1 font-mono text-sm">{temporaryPassword}</p></div>}
{message && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 text-sm text-success shadow-soft">{message}</div>}
{error && <div className="fixed right-4 top-4 z-50 rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{error}</div>}
<form onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))} className="rounded-lg border border-border bg-surface p-5 shadow-soft">
<h2 className="text-xl font-semibold">{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{(["first_name", "last_name", "email", "temporary_password"] as const).map((field) => (
<label key={field} className="block">
<span className="text-sm font-medium">{field === "first_name" ? "Vorname" : field === "last_name" ? "Nachname" : field === "email" ? "E-Mail" : "Temporäres Passwort"}</span>
<input type={field === "email" ? "email" : "text"} {...form.register(field)} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
</label>
))}
<label className="block">
<span className="text-sm font-medium">Rolle</span>
<select {...form.register("role")} className="mt-2 h-12 w-full rounded-lg border border-border px-4">
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
<label className="flex items-center gap-3 pt-7"><input type="checkbox" {...form.register("is_active")} /> Aktiv</label>
<label className="flex items-center gap-3 pt-7"><input type="checkbox" {...form.register("must_change_password")} /> Passwortwechsel erzwingen</label>
</div>
<div className="mt-5 flex justify-end gap-3">
<button type="button" className="btn btn-secondary" onClick={() => form.reset()}>Zurücksetzen</button>
<button type="submit" className="btn btn-primary">{saveMutation.isPending ? <span className="spinner" /> : null} Speichern</button>
</div>
</form>
</div>
);
}

View file

@ -8,11 +8,13 @@ import {
LayoutDashboard,
LogOut,
MapPin,
Shield,
Stethoscope,
UserRound
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useAuth } from "@/components/auth";
import { BrandLogo } from "@/components/brand/brand-logo";
@ -24,13 +26,21 @@ const navigation = [
{ href: "/devices", label: "Geraete", icon: Stethoscope },
{ href: "/equipment", label: "Pruefmittel", icon: Gauge },
{ href: "/validations", label: "Validierungen", icon: ClipboardCheck },
{ href: "/documents", label: "Dokumente", icon: FileArchive }
{ href: "/documents", label: "Dokumente", icon: FileArchive },
{ href: "/users", label: "Benutzer", icon: Shield, adminOnly: true }
];
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const auth = useAuth();
useEffect(() => {
if (auth.user?.must_change_password && pathname !== "/profile/security") {
router.push("/profile/security");
}
}, [auth.user?.must_change_password, pathname, router]);
return (
<div className="min-h-screen bg-background text-text">
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
@ -40,6 +50,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<nav className="space-y-1">
{navigation.map((item, index) => {
const Icon = item.icon;
if ("adminOnly" in item && item.adminOnly && auth.user?.role !== "admin") {
return null;
}
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link

View file

@ -2,11 +2,14 @@
import { useRouter } from "next/navigation";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { apiGet, User } from "@/lib/api";
type AuthContextValue = {
token: string | null;
setToken: (value: string | null) => void;
logout: () => void;
user: User | null;
refreshUser: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
@ -14,11 +17,23 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [token, setTokenState] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
setTokenState(window.localStorage.getItem("atlas_token"));
const stored = window.localStorage.getItem("atlas_token");
setTokenState(stored);
if (!stored) return;
void apiGet<User>("/auth/me", stored).then(setUser).catch(() => setUser(null));
}, []);
useEffect(() => {
if (!token) {
setUser(null);
return;
}
void apiGet<User>("/auth/me", token).then(setUser).catch(() => setUser(null));
}, [token]);
const value = useMemo<AuthContextValue>(() => {
const setToken = (next: string | null) => {
setTokenState(next);
@ -34,9 +49,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout: () => {
setToken(null);
router.push("/login");
},
user,
refreshUser: () => {
if (token) {
void apiGet<User>("/auth/me", token).then(setUser).catch(() => setUser(null));
}
}
};
}, [router, token]);
}, [router, token, user]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
@ -48,4 +69,3 @@ export function useAuth() {
}
return value;
}

View file

@ -1,4 +1,4 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000/api/v1";
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
export type Entity = {
id: string;
@ -61,6 +61,17 @@ export type Equipment = Entity & {
status: "green" | "yellow" | "red";
};
export type User = Entity & {
email: string;
first_name: string;
last_name: string;
role: "admin" | "pruefer" | "mitarbeiter" | "leser";
is_active: boolean;
must_change_password: boolean;
last_login_at?: string | null;
password_changed_at?: string | null;
};
export type ValidationItem = Entity & {
report_number: string;
customer_id: string;
@ -154,3 +165,14 @@ export async function apiDelete(path: string, token: string): Promise<void> {
throw new Error(`API request failed: ${response.status}`);
}
}
export async function apiFetch<T>(path: string, token: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers ?? {}) }
});
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<T>;
}