Files
IdP/apps/frontend/app/admin/rbac/page.tsx
2026-06-25 15:14:50 +03:00

228 lines
9.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Pencil, Plus, ShieldCheck, Trash2 } from 'lucide-react';
import { AdminShell } from '@/components/id/admin-shell';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { AdminPermission, AdminRole, apiFetch } from '@/lib/api';
type RoleFormState = {
slug: string;
name: string;
description: string;
permissionSlugs: string[];
};
const emptyForm: RoleFormState = { slug: '', name: '', description: '', permissionSlugs: [] };
export default function AdminRbacPage() {
const { token, user } = useAuth();
const { showToast } = useToast();
const [roles, setRoles] = useState<AdminRole[]>([]);
const [permissions, setPermissions] = useState<AdminPermission[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingRole, setEditingRole] = useState<AdminRole | null>(null);
const [form, setForm] = useState<RoleFormState>(emptyForm);
async function reloadRoles() {
if (!token) return;
const response = await apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token);
setRoles(response.roles ?? []);
}
useEffect(() => {
if (!token || !user?.canManageRoles) return;
Promise.all([
apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token),
apiFetch<{ permissions: AdminPermission[] }>('/admin/rbac/permissions', {}, token)
])
.then(([rolesResponse, permissionsResponse]) => {
setRoles(rolesResponse.roles ?? []);
setPermissions(permissionsResponse.permissions ?? []);
})
.catch((error) => showToast(error instanceof Error ? error.message : 'Не удалось загрузить роли'))
.finally(() => setLoading(false));
}, [showToast, token, user?.canManageRoles]);
function openCreateDialog() {
setEditingRole(null);
setForm(emptyForm);
setDialogOpen(true);
}
function openEditDialog(role: AdminRole) {
setEditingRole(role);
setForm({
slug: role.slug,
name: role.name,
description: role.description ?? '',
permissionSlugs: role.permissions.map((permission) => permission.slug)
});
setDialogOpen(true);
}
async function handleSaveRole() {
if (!token) return;
setSaving(true);
try {
if (editingRole) {
await apiFetch(`/admin/rbac/roles/${editingRole.slug}`, {
method: 'PATCH',
body: JSON.stringify({
name: form.name,
description: form.description || undefined,
permissionSlugs: form.permissionSlugs
})
}, token);
showToast('Роль обновлена');
} else {
await apiFetch('/admin/rbac/roles', {
method: 'POST',
body: JSON.stringify(form)
}, token);
showToast('Роль создана');
}
setDialogOpen(false);
setForm(emptyForm);
setEditingRole(null);
await reloadRoles();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось сохранить роль');
} finally {
setSaving(false);
}
}
async function handleDeleteRole(role: AdminRole) {
if (!token || role.isSystem) return;
if (!window.confirm(`Удалить роль «${role.name}»? Она будет снята со всех пользователей.`)) return;
setSaving(true);
try {
await apiFetch(`/admin/rbac/roles/${role.slug}`, { method: 'DELETE' }, token);
showToast('Роль удалена');
await reloadRoles();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось удалить роль');
} finally {
setSaving(false);
}
}
if (!user?.canManageRoles) {
return (
<AdminShell active="/admin/rbac">
<p className="text-[#667085]">Управление ролями доступно пользователям с правом rbac.manage.</p>
</AdminShell>
);
}
return (
<AdminShell active="/admin/rbac">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-medium">Роли и права</h2>
<p className="text-sm text-[#667085]">Редактируйте роли, назначайте права всем пользователям или отдельным аккаунтам</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4" />
Создать роль
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
<Loader2 className="h-5 w-5 animate-spin" />
Загрузка...
</div>
) : (
<div className="grid gap-4 md:grid-cols-3">
{roles.map((role) => (
<Card key={role.id} className="bg-[#f8f9fb] shadow-none">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<ShieldCheck className="h-6 w-6 shrink-0" />
<div className="flex gap-1">
<Button variant="ghost" size="icon" aria-label="Редактировать роль" onClick={() => openEditDialog(role)}>
<Pencil className="h-4 w-4" />
</Button>
{!role.isSystem ? (
<Button variant="ghost" size="icon" aria-label="Удалить роль" disabled={saving} onClick={() => void handleDeleteRole(role)}>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
) : null}
</div>
</div>
<CardTitle>{role.name}</CardTitle>
<CardDescription>
{role.permissions.length} прав · {role.slug}
{role.isSystem ? ' · системная' : ''}
{role.isDefault ? ' · по умолчанию' : ''}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{role.permissions.length ? (
role.permissions.map((permission) => (
<div key={permission.id} className="rounded-xl bg-white px-3 py-2 text-sm">
<div className="font-medium">{permission.name}</div>
<div className="text-xs text-[#667085]">{permission.slug}</div>
</div>
))
) : (
<p className="text-sm text-[#667085]">Права не назначены</p>
)}
</CardContent>
</Card>
))}
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRole ? `Редактирование: ${editingRole.name}` : 'Новая роль'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{!editingRole ? (
<Input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="slug, например support" />
) : (
<p className="rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm text-[#667085]">Slug: {editingRole.slug}</p>
)}
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Название роли" />
<Input value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Описание" />
<div className="space-y-2">
<p className="text-sm font-medium">Права</p>
{permissions.map((permission) => (
<label key={permission.id} className="flex items-center gap-2 rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm">
<input
type="checkbox"
checked={form.permissionSlugs.includes(permission.slug)}
onChange={(event) =>
setForm((current) => ({
...current,
permissionSlugs: event.target.checked
? [...current.permissionSlugs, permission.slug]
: current.permissionSlugs.filter((item) => item !== permission.slug)
}))
}
/>
<span>{permission.name}</span>
</label>
))}
</div>
<Button className="w-full" disabled={saving || !form.name.trim() || (!editingRole && !form.slug.trim())} onClick={() => void handleSaveRole()}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : editingRole ? 'Сохранить изменения' : 'Создать роль'}
</Button>
</div>
</DialogContent>
</Dialog>
</AdminShell>
);
}