341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Plus } from "lucide-react";
|
|
|
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
|
import SearchInput from "@/components/common/SearchInput";
|
|
import { useToast } from "@/components/common/ToastProvider";
|
|
import { Button } from "@/components/ui/button";
|
|
import UserFormDialog from "@/components/users/UserFormDialog";
|
|
import UserTable from "@/components/users/UserTable";
|
|
import { api } from "@/lib/api";
|
|
import { hasAllPermissions, hasPermission } from "@/lib/permissions";
|
|
import type { CurrentUser, Role } from "@/types/rbac";
|
|
import type { User, UserPayload, UserRole } from "@/types/user";
|
|
|
|
const pageSize = 10;
|
|
|
|
type StatusFilter = "all" | "active" | "inactive";
|
|
|
|
function getErrorMessage(error: unknown) {
|
|
if (typeof error === "object" && error !== null && "response" in error) {
|
|
const response = (error as { response?: { data?: { detail?: string } } }).response;
|
|
return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden";
|
|
}
|
|
|
|
return "Aktion konnte nicht abgeschlossen werden";
|
|
}
|
|
|
|
function getSortableValue(user: User, key: string) {
|
|
const value = user[key as keyof User];
|
|
return typeof value === "string" ? value.toLowerCase() : value;
|
|
}
|
|
|
|
export default function UsersPage() {
|
|
const { showToast } = useToast();
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [formError, setFormError] = useState("");
|
|
const [deleteError, setDeleteError] = useState("");
|
|
const [pending, setPending] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [roleFilter, setRoleFilter] = useState<UserRole | "all">("all");
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
|
const [sortKey, setSortKey] = useState("created_at");
|
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
|
const [page, setPage] = useState(1);
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
|
const [deleteUser, setDeleteUser] = useState<User | null>(null);
|
|
|
|
const loadUsers = useCallback(async () => {
|
|
try {
|
|
const [usersResponse, meResponse] = await Promise.all([
|
|
api.get<User[]>("/users"),
|
|
api.get<CurrentUser>("/me"),
|
|
]);
|
|
setUsers(usersResponse.data);
|
|
setCurrentUser(meResponse.data);
|
|
|
|
if (hasPermission(meResponse.data, "roles.read")) {
|
|
const rolesResponse = await api.get<Role[]>("/roles");
|
|
setRoles(rolesResponse.data);
|
|
} else {
|
|
const userRoles = usersResponse.data.map((user) => user.primary_role);
|
|
setRoles(userRoles.filter((role, index, all) => (
|
|
all.findIndex((item) => item.id === role.id) === index
|
|
)));
|
|
}
|
|
} catch (err) {
|
|
setError(getErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void loadUsers();
|
|
});
|
|
}, [loadUsers]);
|
|
|
|
const filteredUsers = useMemo(() => {
|
|
const term = search.trim().toLowerCase();
|
|
|
|
return users
|
|
.filter((user) => {
|
|
if (!term) {
|
|
return true;
|
|
}
|
|
|
|
return [
|
|
user.first_name,
|
|
user.last_name,
|
|
user.username,
|
|
user.email,
|
|
user.primary_role.display_name,
|
|
user.primary_role.name,
|
|
].some((value) => value.toLowerCase().includes(term));
|
|
})
|
|
.filter((user) => roleFilter === "all" || user.primary_role.name === roleFilter)
|
|
.filter((user) => {
|
|
if (statusFilter === "all") {
|
|
return true;
|
|
}
|
|
|
|
return statusFilter === "active" ? user.is_active : !user.is_active;
|
|
})
|
|
.sort((first, second) => {
|
|
const firstValue = getSortableValue(first, sortKey);
|
|
const secondValue = getSortableValue(second, sortKey);
|
|
|
|
if (firstValue === secondValue) {
|
|
return 0;
|
|
}
|
|
|
|
const result = firstValue > secondValue ? 1 : -1;
|
|
return sortDirection === "asc" ? result : -result;
|
|
});
|
|
}, [roleFilter, search, sortDirection, sortKey, statusFilter, users]);
|
|
|
|
const pageCount = Math.max(1, Math.ceil(filteredUsers.length / pageSize));
|
|
const currentPage = Math.min(page, pageCount);
|
|
const pageUsers = filteredUsers.slice(
|
|
(currentPage - 1) * pageSize,
|
|
currentPage * pageSize,
|
|
);
|
|
|
|
function handleSort(key: string) {
|
|
if (sortKey === key) {
|
|
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
|
return;
|
|
}
|
|
|
|
setSortKey(key);
|
|
setSortDirection("asc");
|
|
}
|
|
|
|
function openCreateDialog() {
|
|
setSelectedUser(null);
|
|
setFormError("");
|
|
setFormOpen(true);
|
|
}
|
|
|
|
function openEditDialog(user: User) {
|
|
setSelectedUser(user);
|
|
setFormError("");
|
|
setFormOpen(true);
|
|
}
|
|
|
|
async function saveUser(payload: UserPayload) {
|
|
setPending(true);
|
|
setFormError("");
|
|
|
|
try {
|
|
if (selectedUser) {
|
|
const response = await api.put<User>(`/users/${selectedUser.id}`, payload);
|
|
setUsers((current) => current.map((user) => (
|
|
user.id === selectedUser.id ? response.data : user
|
|
)));
|
|
showToast({ type: "success", title: "Benutzer aktualisiert", description: response.data.username });
|
|
} else {
|
|
const response = await api.post<User>("/users", payload);
|
|
setUsers((current) => [response.data, ...current]);
|
|
showToast({ type: "success", title: "Benutzer erstellt", description: response.data.username });
|
|
}
|
|
|
|
setFormOpen(false);
|
|
} catch (err) {
|
|
const message = getErrorMessage(err);
|
|
setFormError(message);
|
|
showToast({ type: "error", title: "Benutzer konnte nicht gespeichert werden", description: message });
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!deleteUser) {
|
|
return;
|
|
}
|
|
|
|
setPending(true);
|
|
setDeleteError("");
|
|
|
|
try {
|
|
await api.delete(`/users/${deleteUser.id}`);
|
|
setUsers((current) => current.filter((user) => user.id !== deleteUser.id));
|
|
showToast({ type: "success", title: "Benutzer gelöscht", description: deleteUser.username });
|
|
setDeleteUser(null);
|
|
} catch (err) {
|
|
const message = getErrorMessage(err);
|
|
setDeleteError(message);
|
|
showToast({ type: "error", title: "Benutzer konnte nicht gelöscht werden", description: message });
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-slate-950">Benutzer</h1>
|
|
<p className="mt-1 text-sm text-slate-500">
|
|
{filteredUsers.length} von {users.length} Benutzern
|
|
</p>
|
|
</div>
|
|
|
|
{hasAllPermissions(currentUser, ["users.create", "roles.assign"]) && (
|
|
<Button type="button" onClick={openCreateDialog}>
|
|
<Plus size={16} />
|
|
Neuer Benutzer
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
|
|
<SearchInput
|
|
value={search}
|
|
onChange={(value) => {
|
|
setSearch(value);
|
|
setPage(1);
|
|
}}
|
|
placeholder="Benutzer suchen"
|
|
/>
|
|
|
|
<select
|
|
value={roleFilter}
|
|
onChange={(event) => {
|
|
setRoleFilter(event.target.value as UserRole | "all");
|
|
setPage(1);
|
|
}}
|
|
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
>
|
|
<option value="all">Alle Rollen</option>
|
|
{roles.map((role) => (
|
|
<option key={role.id} value={role.name}>
|
|
{role.display_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(event) => {
|
|
setStatusFilter(event.target.value as StatusFilter);
|
|
setPage(1);
|
|
}}
|
|
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
>
|
|
<option value="all">Alle Status</option>
|
|
<option value="active">Aktiv</option>
|
|
<option value="inactive">Inaktiv</option>
|
|
</select>
|
|
</div>
|
|
|
|
<UserTable
|
|
users={pageUsers}
|
|
loading={loading}
|
|
error={error}
|
|
sortKey={sortKey}
|
|
sortDirection={sortDirection}
|
|
canUpdate={hasPermission(currentUser, "users.update")}
|
|
canDelete={hasPermission(currentUser, "users.delete")}
|
|
onSort={handleSort}
|
|
onEdit={openEditDialog}
|
|
onDelete={setDeleteUser}
|
|
/>
|
|
|
|
<div className="flex items-center justify-between text-sm text-slate-600">
|
|
<span>
|
|
Seite {currentPage} von {pageCount}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={currentPage === 1}
|
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
>
|
|
Zurück
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={currentPage === pageCount}
|
|
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
|
|
>
|
|
Weiter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<UserFormDialog
|
|
open={formOpen}
|
|
user={selectedUser}
|
|
pending={pending}
|
|
serverError={formError}
|
|
roles={roles}
|
|
onOpenChange={setFormOpen}
|
|
onSubmit={saveUser}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(deleteUser)}
|
|
title="Benutzer löschen"
|
|
description="Möchten Sie den Benutzer wirklich löschen?"
|
|
pending={pending}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteUser(null);
|
|
setDeleteError("");
|
|
}
|
|
}}
|
|
onConfirm={confirmDelete}
|
|
>
|
|
{deleteUser && (
|
|
<dl className="grid gap-2 text-sm">
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-slate-500">Benutzername</dt>
|
|
<dd className="font-medium">{deleteUser.username}</dd>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-slate-500">E-Mail</dt>
|
|
<dd className="font-medium">{deleteUser.email}</dd>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-slate-500">Rolle</dt>
|
|
<dd className="font-medium">{deleteUser.primary_role.display_name}</dd>
|
|
</div>
|
|
{deleteError && <p className="text-red-600">{deleteError}</p>}
|
|
</dl>
|
|
)}
|
|
</ConfirmDialog>
|
|
</div>
|
|
);
|
|
}
|