first commit
This commit is contained in:
23
apps/frontend/app/admin/layout.tsx
Normal file
23
apps/frontend/app/admin/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, isPinLocked, hasStoredSession } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!user && !isPinLocked && !hasStoredSession) {
|
||||
router.replace('/auth/login');
|
||||
return;
|
||||
}
|
||||
if (user && !user.canAccessAdmin) {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [hasStoredSession, isLoading, isPinLocked, router, user]);
|
||||
|
||||
return children;
|
||||
}
|
||||
262
apps/frontend/app/admin/oauth/page.tsx
Normal file
262
apps/frontend/app/admin/oauth/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Copy, KeyRound, Loader2, LockKeyhole, Plus, RefreshCw } 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 { OAuthClient, OAuthScope, apiFetch } from '@/lib/api';
|
||||
|
||||
export default function AdminOAuthPage() {
|
||||
const { token } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [clients, setClients] = useState<OAuthClient[]>([]);
|
||||
const [scopes, setScopes] = useState<OAuthScope[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null);
|
||||
const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [clientsResponse, scopesResponse] = await Promise.all([
|
||||
apiFetch<{ clients: OAuthClient[] }>('/admin/rbac/oauth-clients', {}, token),
|
||||
apiFetch<{ scopes: OAuthScope[] }>('/admin/rbac/oauth-scopes', {}, token)
|
||||
]);
|
||||
setClients(clientsResponse.clients ?? []);
|
||||
setScopes(scopesResponse.scopes ?? []);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить OAuth-приложения');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!token) return;
|
||||
const redirectUris = form.redirectUris
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (!form.name.trim() || !redirectUris.length || !form.selectedScopes.length) {
|
||||
showToast('Заполните название, redirect URI и scopes');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await apiFetch<OAuthClient>('/admin/rbac/oauth-clients', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
redirectUris,
|
||||
scopes: form.selectedScopes,
|
||||
type: form.type
|
||||
})
|
||||
}, token);
|
||||
showToast('OAuth-приложение создано');
|
||||
setDialogOpen(false);
|
||||
setForm({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] });
|
||||
if (created.clientSecret) {
|
||||
setSecretDialog({ clientId: created.clientId, clientSecret: created.clientSecret });
|
||||
}
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось создать приложение');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotateSecret(clientId: string) {
|
||||
if (!token) return;
|
||||
try {
|
||||
const response = await apiFetch<{ clientId: string; clientSecret: string }>(`/admin/rbac/oauth-clients/${clientId}/rotate-secret`, { method: 'POST' }, token);
|
||||
setSecretDialog(response);
|
||||
showToast('Новый client secret сгенерирован');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось перевыпустить secret');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(client: OAuthClient) {
|
||||
if (!token) return;
|
||||
try {
|
||||
await apiFetch(`/admin/rbac/oauth-clients/${client.clientId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !client.isActive })
|
||||
}, token);
|
||||
showToast(client.isActive ? 'Приложение отключено' : 'Приложение включено');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось обновить приложение');
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(value: string, label: string) {
|
||||
void navigator.clipboard.writeText(value);
|
||||
showToast(`${label} скопирован`);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell active="/admin/oauth">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-medium">OAuth-приложения</h2>
|
||||
<p className="text-sm text-[#667085]">Создание клиентов как на oauth.yandex.ru: client_id, secret, redirect URI и scopes</p>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<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-2">
|
||||
{clients.map((client) => (
|
||||
<Card key={client.id} className={client.isActive ? '' : 'opacity-70'}>
|
||||
<CardHeader>
|
||||
<LockKeyhole className="h-6 w-6" />
|
||||
<CardTitle>{client.name}</CardTitle>
|
||||
<CardDescription>{client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="font-medium">Client ID</span>
|
||||
<Button variant="ghost" size="icon" aria-label="Скопировать client id" onClick={() => copyText(client.clientId, 'Client ID')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<code className="break-all text-xs">{client.clientId}</code>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||
<div className="mb-2 font-medium">Redirect URI</div>
|
||||
{client.redirectUris.map((uri) => (
|
||||
<div key={uri} className="break-all text-xs text-[#667085]">
|
||||
{uri}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Scopes
|
||||
</div>
|
||||
<p>{client.scopes.join(', ')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{client.type !== 'PUBLIC' ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => void handleRotateSecret(client.clientId)}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Новый secret
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="secondary" size="sm" onClick={() => void handleToggleActive(client)}>
|
||||
{client.isActive ? 'Отключить' : 'Включить'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{!clients.length ? <p className="text-[#667085]">OAuth-приложения ещё не созданы</p> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новое OAuth-приложение</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Название</label>
|
||||
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Lendry Docs" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Тип клиента</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-[#eceef4] bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="CONFIDENTIAL">Confidential (с client secret)</option>
|
||||
<option value="PUBLIC">Public (без secret)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Redirect URI (по одному на строку)</label>
|
||||
<textarea
|
||||
value={form.redirectUris}
|
||||
onChange={(event) => setForm((current) => ({ ...current, redirectUris: event.target.value }))}
|
||||
placeholder="https://app.example.com/oauth/callback"
|
||||
className="min-h-[96px] w-full rounded-xl border border-[#eceef4] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Scopes</label>
|
||||
<div className="space-y-2">
|
||||
{scopes.map((scope) => (
|
||||
<label key={scope.id} className="flex items-center gap-2 rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.selectedScopes.includes(scope.slug)}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
selectedScopes: event.target.checked
|
||||
? [...current.selectedScopes, scope.slug]
|
||||
: current.selectedScopes.filter((item) => item !== scope.slug)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">{scope.name}</span>
|
||||
<span className="text-[#667085]">({scope.slug})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" disabled={creating} onClick={() => void handleCreate()}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Создать приложение'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(secretDialog)} onOpenChange={(open) => !open && setSecretDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Client Secret</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-[#667085]">Сохраните secret сейчас — он больше не будет показан полностью.</p>
|
||||
<div className="rounded-2xl bg-[#f4f5f8] p-4">
|
||||
<p className="mb-1 text-xs text-[#667085]">Client ID</p>
|
||||
<code className="break-all text-sm">{secretDialog?.clientId}</code>
|
||||
<p className="mb-1 mt-4 text-xs text-[#667085]">Client Secret</p>
|
||||
<code className="break-all text-sm">{secretDialog?.clientSecret}</code>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => secretDialog && copyText(secretDialog.clientSecret, 'Client Secret')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
Скопировать secret
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
5
apps/frontend/app/admin/page.tsx
Normal file
5
apps/frontend/app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminIndexPage() {
|
||||
redirect('/admin/users');
|
||||
}
|
||||
143
apps/frontend/app/admin/rbac/page.tsx
Normal file
143
apps/frontend/app/admin/rbac/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Plus, ShieldCheck } 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';
|
||||
|
||||
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 [creating, setCreating] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState({ slug: '', name: '', description: '', permissionSlugs: [] as string[] });
|
||||
|
||||
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]);
|
||||
|
||||
async function handleCreateRole() {
|
||||
if (!token) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await apiFetch('/admin/rbac/roles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form)
|
||||
}, token);
|
||||
showToast('Роль создана');
|
||||
setDialogOpen(false);
|
||||
setForm({ slug: '', name: '', description: '', permissionSlugs: [] });
|
||||
const response = await apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token);
|
||||
setRoles(response.roles ?? []);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось создать роль');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user?.canManageRoles) {
|
||||
return (
|
||||
<AdminShell active="/admin/rbac">
|
||||
<p className="text-[#667085]">Управление ролями доступно только супер-администратору.</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={() => setDialogOpen(true)}>
|
||||
<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>
|
||||
<ShieldCheck className="h-6 w-6" />
|
||||
<CardTitle>{role.name}</CardTitle>
|
||||
<CardDescription>{role.permissions.length} прав · {role.slug}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{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>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новая роль</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="slug, например support" />
|
||||
<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={creating} onClick={() => void handleCreateRole()}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Создать роль'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
219
apps/frontend/app/admin/settings/page.tsx
Normal file
219
apps/frontend/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Chrome, Loader2, Save, Settings2, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { AdminShell } from '@/components/id/admin-shell';
|
||||
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
|
||||
import { getSettingMeta, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog';
|
||||
|
||||
function parseBoolean(value: string) {
|
||||
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { token, user } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { refreshPublicSettings } = usePublicSettings();
|
||||
const [settings, setSettings] = useState<SystemSetting[]>([]);
|
||||
const [providers, setProviders] = useState<SocialProvider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !user?.canManageSettings) return;
|
||||
Promise.all([
|
||||
apiFetch<{ settings: SystemSetting[] }>('/admin/settings', {}, token),
|
||||
apiFetch<{ providers: SocialProvider[] }>('/admin/settings/oauth/providers', {}, token)
|
||||
])
|
||||
.then(([settingsResponse, providersResponse]) => {
|
||||
setSettings(sortSettingsByCatalog(settingsResponse.settings ?? []));
|
||||
setProviders(providersResponse.providers ?? []);
|
||||
})
|
||||
.catch((error) => showToast(error instanceof Error ? error.message : 'Не удалось загрузить настройки'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast, token, user?.canManageSettings]);
|
||||
|
||||
const groupedSettings = useMemo(() => {
|
||||
const groups = new Map<string, SystemSetting[]>();
|
||||
for (const setting of settings) {
|
||||
const meta = getSettingMeta(setting.key);
|
||||
const group = meta?.group ?? 'other';
|
||||
const list = groups.get(group) ?? [];
|
||||
list.push(setting);
|
||||
groups.set(group, list);
|
||||
}
|
||||
return groups;
|
||||
}, [settings]);
|
||||
|
||||
async function saveSetting(setting: SystemSetting) {
|
||||
if (!token) return;
|
||||
setSavingKey(setting.key);
|
||||
try {
|
||||
const updated = await apiFetch<SystemSetting>(
|
||||
'/admin/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(buildSystemSettingPayload(setting))
|
||||
},
|
||||
token
|
||||
);
|
||||
setSettings((current) => sortSettingsByCatalog(current.map((item) => (item.key === updated.key ? updated : item))));
|
||||
if (updated.key === 'PROJECT_NAME' || updated.key === 'PROJECT_TAGLINE' || updated.key === 'LDAP_ENABLED' || updated.key === 'LDAP_USE_LDAPS') {
|
||||
await refreshPublicSettings();
|
||||
}
|
||||
showToast('Настройка сохранена');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройку');
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSettingValue(key: string, value: string) {
|
||||
setSettings((current) => current.map((item) => (item.key === key ? { ...item, value } : item)));
|
||||
}
|
||||
|
||||
function toProviderPayload(provider: SocialProvider) {
|
||||
return {
|
||||
providerName: provider.providerName,
|
||||
clientId: provider.clientId,
|
||||
isEnabled: provider.isEnabled
|
||||
};
|
||||
}
|
||||
|
||||
async function toggleProvider(provider: SocialProvider) {
|
||||
if (!token) return;
|
||||
try {
|
||||
const updated = await apiFetch<SocialProvider>('/admin/settings/oauth/providers', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
|
||||
}, token);
|
||||
setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item)));
|
||||
showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось обновить провайдера');
|
||||
}
|
||||
}
|
||||
|
||||
if (!user?.canManageSettings) {
|
||||
return (
|
||||
<AdminShell active="/admin/settings">
|
||||
<p className="text-[#667085]">У вас нет прав на изменение глобальных настроек.</p>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell active="/admin/settings">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-medium">Глобальные настройки</h2>
|
||||
<p className="mt-2 text-[#667085]">Параметры хранятся в SystemSetting и применяются сервисами без релиза frontend.</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => (
|
||||
<section key={groupKey} className="mb-10">
|
||||
<h3 className="mb-4 text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
|
||||
<div className="space-y-3">
|
||||
{groupSettings.map((setting) => {
|
||||
const meta = getSettingMeta(setting.key);
|
||||
const label = meta?.label ?? setting.key;
|
||||
const type = meta?.type ?? 'text';
|
||||
|
||||
return (
|
||||
<div key={setting.key} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Settings2 className="h-4 w-4 shrink-0" />
|
||||
{label}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{type === 'boolean' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
updateSettingValue(setting.key, parseBoolean(setting.value) ? 'false' : 'true');
|
||||
}}
|
||||
>
|
||||
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
|
||||
value={setting.value}
|
||||
className={setting.isSecret ? 'w-[220px] bg-white' : 'w-[180px] bg-white'}
|
||||
onChange={(event) => updateSettingValue(setting.key, event.target.value)}
|
||||
/>
|
||||
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button disabled={savingKey === setting.key} onClick={() => void saveSetting(setting)}>
|
||||
{savingKey === setting.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="mt-10">
|
||||
<h3 className="text-xl font-medium">OAuth Social Providers</h3>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{(providers.length ? providers : [{ id: 'google', providerName: 'google', clientId: '', isEnabled: false }, { id: 'yandex', providerName: 'yandex', clientId: '', isEnabled: false }]).map((provider) => (
|
||||
<div key={provider.providerName} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
{provider.providerName === 'google' ? <Chrome className="h-7 w-7" /> : <div className="flex h-8 w-8 items-center justify-center rounded-full bg-red-500 font-bold text-white">Я</div>}
|
||||
<button type="button" aria-label="Переключить провайдера" onClick={() => void toggleProvider(provider)}>
|
||||
{provider.isEnabled ? <ToggleRight className="h-8 w-8 text-green-600" /> : <ToggleLeft className="h-8 w-8 text-[#a8adbc]" />}
|
||||
</button>
|
||||
</div>
|
||||
<h4 className="mt-5 font-semibold capitalize">{provider.providerName}</h4>
|
||||
<p className="mt-1 text-sm text-[#667085]">{provider.isEnabled ? 'Провайдер включен глобально.' : 'Провайдер отключен.'}</p>
|
||||
<Input
|
||||
className="mt-4 bg-white"
|
||||
value={provider.clientId}
|
||||
placeholder="Client ID"
|
||||
onChange={(event) => setProviders((current) => current.map((item) => (item.providerName === provider.providerName ? { ...item, clientId: event.target.value } : item)))}
|
||||
/>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
void apiFetch('/admin/settings/oauth/providers', { method: 'PUT', body: JSON.stringify(toProviderPayload(provider)) }, token)
|
||||
.then(() => showToast('Провайдер сохранён'))
|
||||
.catch((error) => showToast(error instanceof Error ? error.message : 'Ошибка сохранения'))
|
||||
}
|
||||
>
|
||||
Сохранить провайдера
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
323
apps/frontend/app/admin/users/page.tsx
Normal file
323
apps/frontend/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCog } from 'lucide-react';
|
||||
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { AdminRole, AdminUser, apiFetch } from '@/lib/api';
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
ACTIVE: 'Активен',
|
||||
SUSPENDED: 'Заблокирован',
|
||||
DELETED: 'Удалён'
|
||||
};
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
admin: 'Администратор',
|
||||
moderator: 'Модератор',
|
||||
manager: 'Менеджер',
|
||||
'super-admin': 'Супер-админ'
|
||||
};
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { token, user: currentUser } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [roles, setRoles] = useState<AdminRole[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [dialog, setDialog] = useState<'password' | 'roles' | null>(null);
|
||||
const [documentsUser, setDocumentsUser] = useState<AdminUser | null>(null);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<{ users: AdminUser[] }>(`/admin/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, {}, token);
|
||||
setUsers(response.users ?? []);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить пользователей');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, showToast, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !currentUser?.canManageRoles) return;
|
||||
apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token)
|
||||
.then((response) => setRoles(response.roles ?? []))
|
||||
.catch(() => undefined);
|
||||
}, [currentUser?.canManageRoles, token]);
|
||||
|
||||
const assignableRoles = useMemo(() => roles.filter((role) => role.slug !== 'super-admin'), [roles]);
|
||||
|
||||
async function handleSuspend(user: AdminUser) {
|
||||
if (!token) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${user.id}/suspend`, { method: 'POST' }, token);
|
||||
showToast('Пользователь заблокирован');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось заблокировать пользователя');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (!token || !selectedUser || password.length < 8) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${selectedUser.id}/reset-password`, { method: 'POST', body: JSON.stringify({ newPassword: password }) }, token);
|
||||
showToast('Пароль обновлён');
|
||||
setDialog(null);
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось сбросить пароль');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSuperAdmin(user: AdminUser) {
|
||||
if (!token || !currentUser?.canManageRoles) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${user.id}/super-admin`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isSuperAdmin: !user.isSuperAdmin })
|
||||
}, token);
|
||||
showToast(user.isSuperAdmin ? 'Права супер-админа сняты' : 'Пользователь назначен супер-админом');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось изменить права');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignRole(roleSlug: string) {
|
||||
if (!token || !selectedUser) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<{ roles: string[] }>(`/admin/rbac/users/${selectedUser.id}/roles`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ roleSlug })
|
||||
}, token);
|
||||
setSelectedUser({ ...selectedUser, roles: response.roles ?? [] });
|
||||
showToast('Роль назначена');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось назначить роль');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveRole(roleSlug: string) {
|
||||
if (!token || !selectedUser) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/rbac/users/${selectedUser.id}/roles/${roleSlug}`, { method: 'DELETE' }, token);
|
||||
setSelectedUser({ ...selectedUser, roles: (selectedUser.roles ?? []).filter((role) => role !== roleSlug) });
|
||||
showToast('Роль снята');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось снять роль');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRoles(user: AdminUser) {
|
||||
const userRoles = user.roles ?? [];
|
||||
const labels = user.isSuperAdmin ? ['Супер-админ', ...userRoles.map((role) => roleLabels[role] ?? role)] : userRoles.map((role) => roleLabels[role] ?? role);
|
||||
if (!labels.length) return 'Пользователь';
|
||||
return labels.join(', ');
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell active="/admin/users">
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-medium">Пользователи</h2>
|
||||
<p className="text-sm text-[#667085]">Поиск, блокировка, сброс пароля и управление доступом</p>
|
||||
</div>
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Поиск по имени, почте или телефону" className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#eceef4] bg-white">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Пользователь</TableHead>
|
||||
<TableHead>Телефон</TableHead>
|
||||
<TableHead>Роли</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-sm text-[#667085]">{user.email ?? user.username ?? '—'}</div>
|
||||
</TableCell>
|
||||
<TableCell>{user.phone ?? '—'}</TableCell>
|
||||
<TableCell>{renderRoles(user)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
{currentUser?.canManageUsers ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Сбросить пароль"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setPassword('');
|
||||
setDialog('password');
|
||||
}}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="icon" aria-label="Заблокировать" disabled={actionLoading || user.status === 'SUSPENDED'} onClick={() => void handleSuspend(user)}>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{currentUser?.canViewUserDocuments ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Документы пользователя"
|
||||
disabled={actionLoading}
|
||||
onClick={() => setDocumentsUser(user)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{currentUser?.canManageRoles ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Управление ролями"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setDialog('roles');
|
||||
}}
|
||||
>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={user.isSuperAdmin ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
aria-label="Супер-админ"
|
||||
disabled={actionLoading || user.id === currentUser?.id}
|
||||
onClick={() => void handleToggleSuperAdmin(user)}
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialog === 'password'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Сброс пароля</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="mb-4 text-sm text-[#667085]">Новый пароль для {selectedUser?.displayName}</p>
|
||||
<Input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Минимум 8 символов" />
|
||||
<Button className="mt-4 w-full" disabled={actionLoading || password.length < 8} onClick={() => void handleResetPassword()}>
|
||||
{actionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Сохранить пароль'}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={dialog === 'roles'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Роли и доступ</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="mb-4 text-sm text-[#667085]">{selectedUser?.displayName}</p>
|
||||
<div className="space-y-2">
|
||||
{(selectedUser?.roles ?? []).map((role) => (
|
||||
<div key={role} className="flex items-center justify-between rounded-xl bg-[#f4f5f8] px-3 py-2">
|
||||
<span>{roleLabels[role] ?? role}</span>
|
||||
<Button variant="ghost" size="sm" disabled={actionLoading} onClick={() => void handleRemoveRole(role)}>
|
||||
Снять
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!(selectedUser?.roles ?? []).length ? <p className="text-sm text-[#667085]">Роли не назначены</p> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium">Назначить роль</p>
|
||||
{assignableRoles.map((role) => (
|
||||
<Button
|
||||
key={role.id}
|
||||
variant="secondary"
|
||||
className="w-full justify-start"
|
||||
disabled={actionLoading || (selectedUser?.roles ?? []).includes(role.slug)}
|
||||
onClick={() => void handleAssignRole(role.slug)}
|
||||
>
|
||||
<ShieldPlus className="mr-2 h-4 w-4" />
|
||||
{role.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{documentsUser ? (
|
||||
<UserDocumentsDialog
|
||||
open={Boolean(documentsUser)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDocumentsUser(null);
|
||||
}}
|
||||
targetUserId={documentsUser.id}
|
||||
targetUserName={documentsUser.displayName}
|
||||
token={token}
|
||||
/>
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user