feat(rbac): add roles and permissions

This commit is contained in:
Schubert Ferenc 2026-07-02 23:05:59 +02:00
parent 86a32a942c
commit 694b7bd09a
37 changed files with 2682 additions and 218 deletions

View file

@ -20,6 +20,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import UserFormDialog from "@/components/users/UserFormDialog";
import { api } from "@/lib/api";
import { hasPermission } from "@/lib/permissions";
import type { CurrentUser, Role } from "@/types/rbac";
import type { User, UserPayload } from "@/types/user";
function getErrorMessage(error: unknown) {
@ -41,6 +43,8 @@ function formatDateTime(value: string) {
export default function UserDetailPage() {
const params = useParams<{ id: string }>();
const [user, setUser] = useState<User | null>(null);
const [roles, setRoles] = useState<Role[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
@ -52,8 +56,19 @@ export default function UserDetailPage() {
const loadUser = useCallback(async () => {
try {
const response = await api.get<User>(`/users/${params.id}`);
setUser(response.data);
const [userResponse, meResponse] = await Promise.all([
api.get<User>(`/users/${params.id}`),
api.get<CurrentUser>("/me"),
]);
setUser(userResponse.data);
setCurrentUser(meResponse.data);
if (hasPermission(meResponse.data, "roles.read")) {
const rolesResponse = await api.get<Role[]>("/roles");
setRoles(rolesResponse.data);
} else {
setRoles([userResponse.data.primary_role]);
}
} catch (err) {
setError(getErrorMessage(err));
} finally {
@ -147,14 +162,18 @@ export default function UserDetailPage() {
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setPasswordOpen(true)}>
<KeyRound size={16} />
Passwort ändern
</Button>
<Button type="button" onClick={() => setFormOpen(true)}>
<Pencil size={16} />
Bearbeiten
</Button>
{hasPermission(currentUser, "users.password.update") && (
<Button type="button" variant="outline" onClick={() => setPasswordOpen(true)}>
<KeyRound size={16} />
Passwort ändern
</Button>
)}
{hasPermission(currentUser, "users.update") && (
<Button type="button" onClick={() => setFormOpen(true)}>
<Pencil size={16} />
Bearbeiten
</Button>
)}
</div>
</div>
@ -165,7 +184,7 @@ export default function UserDetailPage() {
<Detail label="Nachname" value={user.last_name || "-"} />
<Detail label="Benutzername" value={user.username} />
<Detail label="E-Mail" value={user.email} />
<Detail label="Rolle" value={user.role} />
<Detail label="Rolle" value={user.primary_role.display_name} />
<div>
<dt className="text-sm text-slate-500">Status</dt>
<dd className="mt-1"><StatusBadge active={user.is_active} /></dd>
@ -180,6 +199,7 @@ export default function UserDetailPage() {
user={user}
pending={pending}
serverError={formError}
roles={roles}
onOpenChange={setFormOpen}
onSubmit={saveUser}
/>

View file

@ -9,6 +9,8 @@ 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;
@ -31,6 +33,8 @@ function getSortableValue(user: User, key: string) {
export default function UsersPage() {
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("");
@ -48,8 +52,22 @@ export default function UsersPage() {
const loadUsers = useCallback(async () => {
try {
const response = await api.get<User[]>("/users");
setUsers(response.data);
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 {
@ -77,10 +95,11 @@ export default function UsersPage() {
user.last_name,
user.username,
user.email,
user.role,
user.primary_role.display_name,
user.primary_role.name,
].some((value) => value.toLowerCase().includes(term));
})
.filter((user) => roleFilter === "all" || user.role === roleFilter)
.filter((user) => roleFilter === "all" || user.primary_role.name === roleFilter)
.filter((user) => {
if (statusFilter === "all") {
return true;
@ -182,10 +201,12 @@ export default function UsersPage() {
</p>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={16} />
Neuer Benutzer
</Button>
{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">
@ -207,9 +228,11 @@ export default function UsersPage() {
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>
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="user">Benutzer</option>
{roles.map((role) => (
<option key={role.id} value={role.name}>
{role.display_name}
</option>
))}
</select>
<select
@ -232,6 +255,8 @@ export default function UsersPage() {
error={error}
sortKey={sortKey}
sortDirection={sortDirection}
canUpdate={hasPermission(currentUser, "users.update")}
canDelete={hasPermission(currentUser, "users.delete")}
onSort={handleSort}
onEdit={openEditDialog}
onDelete={setDeleteUser}
@ -266,6 +291,7 @@ export default function UsersPage() {
user={selectedUser}
pending={pending}
serverError={formError}
roles={roles}
onOpenChange={setFormOpen}
onSubmit={saveUser}
/>
@ -295,7 +321,7 @@ export default function UsersPage() {
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Rolle</dt>
<dd className="font-medium">{deleteUser.role}</dd>
<dd className="font-medium">{deleteUser.primary_role.display_name}</dd>
</div>
{deleteError && <p className="text-red-600">{deleteError}</p>}
</dl>