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>
|
||||
);
|
||||
}
|
||||
365
apps/frontend/app/auth/login/page.tsx
Normal file
365
apps/frontend/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronLeft, KeyRound, Mail, Network, Phone, ShieldQuestion, UserRound } from 'lucide-react';
|
||||
import { BrandLogo, ProjectTagline } from '@/components/id/brand-logo';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
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 { OtpInput } from '@/components/ui/otp-input';
|
||||
import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { LoginMethod } from '@/lib/api';
|
||||
|
||||
type Step = 'identify' | 'otp' | 'password' | 'pin' | 'ldap';
|
||||
|
||||
const channelIcon: Record<string, typeof Mail> = {
|
||||
email: Mail,
|
||||
backupEmail: Mail,
|
||||
phone: Phone,
|
||||
backupPhone: Phone,
|
||||
password: KeyRound
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, completePin } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { ldapEnabled, ldapUseLdaps } = usePublicSettings();
|
||||
|
||||
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [country, setCountry] = useState(phoneCountries[0]);
|
||||
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [maskedTarget, setMaskedTarget] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [ldapUsername, setLdapUsername] = useState('');
|
||||
const [ldapPassword, setLdapPassword] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||
const [methods, setMethods] = useState<LoginMethod[]>([]);
|
||||
const [showMethods, setShowMethods] = useState(false);
|
||||
const [step, setStep] = useState<Step>('identify');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
function getIdentifier() {
|
||||
return authTab === 'email' ? email.trim() : toE164(country, phoneNumber);
|
||||
}
|
||||
|
||||
function finishAuth(pinVerified: boolean, sessionId: string) {
|
||||
if (!pinVerified) {
|
||||
setPendingSessionId(sessionId);
|
||||
setStep('pin');
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIdentify(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const identifier = getIdentifier();
|
||||
setRecipient(identifier);
|
||||
const result = await identifyLogin(identifier);
|
||||
setMethods(result.methods ?? []);
|
||||
const masked = await sendLoginOtp(identifier);
|
||||
setMaskedTarget(masked);
|
||||
setOtp('');
|
||||
setStep('otp');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось продолжить вход');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyOtp(code: string) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await verifyLoginOtp(recipient, code);
|
||||
if (response.auth) {
|
||||
finishAuth(response.auth.pinVerified, response.auth.sessionId);
|
||||
} else {
|
||||
showToast('Не удалось завершить вход');
|
||||
}
|
||||
} catch (error) {
|
||||
setOtp('');
|
||||
showToast(error instanceof Error ? error.message : 'Неверный код');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const auth = await loginWithPassword(recipient, password);
|
||||
finishAuth(auth.pinVerified, auth.sessionId);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Неверный пароль');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseMethod(method: LoginMethod) {
|
||||
setShowMethods(false);
|
||||
if (method.kind === 'password') {
|
||||
setPassword('');
|
||||
setStep('password');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const masked = await sendLoginOtp(recipient, method.channel);
|
||||
setMaskedTarget(masked);
|
||||
setOtp('');
|
||||
setStep('otp');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLdapSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const auth = await loginWithLdap(ldapUsername.trim(), ldapPassword);
|
||||
finishAuth(auth.pinVerified, auth.sessionId);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось выполнить LDAP-вход');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePinSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!pendingSessionId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await completePin(pendingSessionId, pin);
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось проверить PIN-код');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetToIdentify() {
|
||||
setStep('identify');
|
||||
setShowMethods(false);
|
||||
setOtp('');
|
||||
setPassword('');
|
||||
setLdapUsername('');
|
||||
setLdapPassword('');
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#596075_0%,#2b2534_45%,#121017_100%)] px-5">
|
||||
<section className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-9 pb-8 pt-10 text-white shadow-2xl">
|
||||
<div className="mb-8 flex flex-col items-center text-center">
|
||||
<BrandLogo size="lg" variant="light" />
|
||||
<h1 className="mt-8 text-xl font-bold">Войдите с ID</h1>
|
||||
</div>
|
||||
|
||||
{step !== 'identify' ? (
|
||||
<button type="button" onClick={resetToIdentify} className="mb-4 flex items-center gap-1 text-sm text-[#b9bdc9] hover:text-white">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Изменить аккаунт
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{step === 'identify' ? (
|
||||
<form onSubmit={handleIdentify}>
|
||||
<Tabs value={authTab} onValueChange={(value) => setAuthTab(value as 'email' | 'phone')} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="email">Почта</TabsTrigger>
|
||||
<TabsTrigger value="phone">Телефон</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="email">
|
||||
<Input
|
||||
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required={authTab === 'email'}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="phone">
|
||||
<PhoneInput country={country} value={phoneNumber} onCountryChange={setCountry} onValueChange={setPhoneNumber} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Отправляем код...' : 'Далее'}
|
||||
</Button>
|
||||
{ldapEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]"
|
||||
onClick={() => {
|
||||
setLdapUsername('');
|
||||
setLdapPassword('');
|
||||
setStep('ldap');
|
||||
}}
|
||||
>
|
||||
<Network className="h-5 w-5" />
|
||||
{ldapUseLdaps ? 'Войти через LDAPS' : 'Войти через LDAP'}
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === 'ldap' ? (
|
||||
<form onSubmit={handleLdapSubmit}>
|
||||
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
|
||||
<Network className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-sm text-[#b9bdc9]">Корпоративный вход</p>
|
||||
<p className="truncate text-sm font-semibold">{ldapUseLdaps ? 'LDAPS' : 'LDAP'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="Логин LDAP"
|
||||
value={ldapUsername}
|
||||
onChange={(event) => setLdapUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
className="mt-3 h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="Пароль LDAP"
|
||||
type="password"
|
||||
value={ldapPassword}
|
||||
onChange={(event) => setLdapPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Проверяем...' : ldapUseLdaps ? 'Войти через LDAPS' : 'Войти через LDAP'}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === 'otp' ? (
|
||||
<div>
|
||||
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
|
||||
<UserRound className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-sm text-[#b9bdc9]">Код отправлен на</p>
|
||||
<p className="truncate text-sm font-semibold">{maskedTarget || recipient}</p>
|
||||
</div>
|
||||
</div>
|
||||
<OtpInput value={otp} onChange={setOtp} onComplete={verifyOtp} disabled={isSubmitting} />
|
||||
{isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Проверяем код...</p> : null}
|
||||
|
||||
{methods.length ? (
|
||||
<div className="mt-6">
|
||||
<Button type="button" variant="outline" size="lg" className="w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setShowMethods((prev) => !prev)}>
|
||||
<ShieldQuestion className="h-5 w-5" />
|
||||
Другой способ входа
|
||||
</Button>
|
||||
{showMethods ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{methods.map((method) => {
|
||||
const Icon = channelIcon[method.channel] ?? KeyRound;
|
||||
return (
|
||||
<button
|
||||
key={`${method.kind}-${method.channel}`}
|
||||
type="button"
|
||||
onClick={() => chooseMethod(method)}
|
||||
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f]"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-[#b9bdc9]" />
|
||||
<span className="text-sm">
|
||||
{method.kind === 'password' ? 'Войти с паролем' : `Код на ${method.masked}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'password' ? (
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
|
||||
<UserRound className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-sm text-[#b9bdc9]">Вход в аккаунт</p>
|
||||
<p className="truncate text-sm font-semibold">{recipient}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="Пароль"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Входим...' : 'Войти'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setStep('otp')}>
|
||||
Войти по коду
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === 'pin' && pendingSessionId ? (
|
||||
<form onSubmit={handlePinSubmit} className="space-y-3">
|
||||
<Input
|
||||
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="PIN"
|
||||
value={pin}
|
||||
onChange={(event) => setPin(event.target.value)}
|
||||
required
|
||||
minLength={4}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Button variant="white" size="lg" className="w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Проверяем...' : 'Подтвердить PIN'}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === 'identify' ? (
|
||||
<div className="mt-3">
|
||||
<Button asChild variant="outline" size="lg" className="w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]">
|
||||
<Link href="/auth/register">Создать ID</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ProjectTagline className="mt-9 text-center text-sm font-semibold" />
|
||||
<p className="mt-2 text-center text-sm font-bold">Узнать больше</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
82
apps/frontend/app/auth/register/page.tsx
Normal file
82
apps/frontend/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { BrandLogo } from '@/components/id/brand-logo';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { sendLoginOtp } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [country, setCountry] = useState(phoneCountries[0]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const recipient = authTab === 'email' ? email.trim() : toE164(country, phoneNumber);
|
||||
await sendLoginOtp(recipient);
|
||||
showToast('Код отправлен. Подтвердите его на экране входа.');
|
||||
router.push('/auth/login');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#5d6578_0%,#2c2736_48%,#111016_100%)] px-5">
|
||||
<section className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-9 py-10 text-white shadow-2xl">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<BrandLogo size="lg" variant="light" />
|
||||
<h1 className="mt-8 text-xl font-bold">Создайте ID</h1>
|
||||
<p className="mt-2 text-sm text-[#b9bdc9]">Введите почту или телефон. Пароль не нужен.</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-3" onSubmit={handleSubmit}>
|
||||
<Tabs value={authTab} onValueChange={(value) => setAuthTab(value as 'email' | 'phone')} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="email">Почта</TabsTrigger>
|
||||
<TabsTrigger value="phone">Телефон</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="email">
|
||||
<Input
|
||||
className="h-[56px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required={authTab === 'email'}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="phone">
|
||||
<PhoneInput country={country} value={phoneNumber} onCountryChange={setCountry} onValueChange={setPhoneNumber} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Button variant="white" size="lg" className="w-full rounded-[18px]" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Отправляем...' : 'Получить код'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-5 flex items-start gap-3 rounded-[22px] bg-[#2a2c36] p-4 text-sm">
|
||||
<ShieldCheck className="mt-0.5 h-5 w-5 text-[#8ec5ff]" />
|
||||
<p>Первый зарегистрированный пользователь автоматически станет суперадминистратором.</p>
|
||||
</div>
|
||||
<Button asChild variant="ghost" className="mt-5 w-full text-white hover:bg-[#2a2c36]">
|
||||
<Link href="/auth/login">У меня уже есть ID</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
291
apps/frontend/app/data/page.tsx
Normal file
291
apps/frontend/app/data/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Car, ChevronRight, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { ActionTile } from '@/components/id/action-tile';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { AvatarDisplay, AvatarUpload } from '@/components/id/avatar-upload';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
import {
|
||||
getDocumentType,
|
||||
indexDocumentsByType,
|
||||
type DocumentTypeCode
|
||||
} from '@/lib/document-catalog';
|
||||
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument } from '@/lib/api';
|
||||
|
||||
function Row({
|
||||
icon: Icon,
|
||||
title,
|
||||
text,
|
||||
onClick,
|
||||
destructive
|
||||
}: {
|
||||
icon: typeof Mail;
|
||||
title: string;
|
||||
text?: string;
|
||||
onClick?: () => void;
|
||||
destructive?: boolean;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl ${destructive ? 'bg-red-50' : 'bg-[#f4f5f8]'}`}>
|
||||
<Icon className={`h-5 w-5 ${destructive ? 'text-red-500' : ''}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`font-medium ${destructive ? 'text-red-600' : ''}`}>{title}</div>
|
||||
{text ? <div className="truncate text-sm text-[#667085]">{text}</div> : null}
|
||||
</div>
|
||||
{onClick ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!onClick) {
|
||||
return <div className="flex items-center gap-4 border-b border-[#eceef4] py-4">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-4 border-b border-[#eceef4] py-4 text-left transition hover:bg-[#fafbfd]"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataPage() {
|
||||
const router = useRouter();
|
||||
const { user, token, refreshProfile, logout } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [backupEmail, setBackupEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [backupPhone, setBackupPhone] = useState('');
|
||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
try {
|
||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
|
||||
setDocuments(response.documents ?? []);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
|
||||
if (message) showToast(message);
|
||||
}
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setDisplayName(user.displayName);
|
||||
setEmail(user.email ?? '');
|
||||
setBackupEmail(user.backupEmail ?? '');
|
||||
setPhone(user.phone ?? '');
|
||||
setBackupPhone(user.backupPhone ?? '');
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && user && !isPinLocked) void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||||
|
||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||
|
||||
function openDocument(type: DocumentTypeCode) {
|
||||
setActiveDocumentType(type);
|
||||
}
|
||||
|
||||
async function updateProfile() {
|
||||
if (!user || !token) return;
|
||||
setIsSaving(true);
|
||||
const trimmedName = displayName.trim();
|
||||
const [firstName, ...rest] = trimmedName.split(' ');
|
||||
try {
|
||||
await apiFetch(`/profile/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ firstName: firstName || undefined, lastName: rest.join(' ') || undefined })
|
||||
}, token);
|
||||
|
||||
const contacts: Record<string, string> = {};
|
||||
if (email.trim() && email.trim() !== (user.email ?? '')) contacts.email = email.trim();
|
||||
if (phone.trim() && phone.trim() !== (user.phone ?? '')) contacts.phone = phone.trim();
|
||||
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
|
||||
if (backupPhone.trim() && backupPhone.trim() !== (user.backupPhone ?? '')) contacts.backupPhone = backupPhone.trim();
|
||||
|
||||
if (Object.keys(contacts).length > 0) {
|
||||
await apiFetch(`/profile/users/${user.id}/contacts`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(contacts)
|
||||
}, token);
|
||||
}
|
||||
|
||||
await refreshProfile();
|
||||
showToast('Профиль обновлён');
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось обновить профиль');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteProfile() {
|
||||
if (!user || !token) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await softDeleteProfile(user.id, token);
|
||||
setDeleteDialogOpen(false);
|
||||
showToast('Профиль удалён');
|
||||
logout();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось удалить профиль');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/data">
|
||||
<div className="mb-8 flex items-center gap-4 rounded-[24px] border border-[#20212b] p-4">
|
||||
{user ? (
|
||||
<AvatarUpload userId={user.id} displayName={user.displayName} hasAvatar={user.hasAvatar} token={token} onUpdated={refreshProfile} />
|
||||
) : (
|
||||
<AvatarDisplay userId="" displayName="" hasAvatar={false} token={null} className="h-14 w-14" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-semibold">{user?.displayName ?? 'Профиль'}</h1>
|
||||
<p className="text-sm text-[#667085]">Логин: {user?.username ?? user?.email ?? user?.phone ?? 'не указан'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-medium">Личные данные</h2>
|
||||
<p className="mt-1 text-sm text-[#667085]">Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.</p>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<Input placeholder="ФИО" value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||||
<Input placeholder="Дата рождения" />
|
||||
<Input placeholder="Основная почта" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
<Input placeholder="Резервная почта" value={backupEmail} onChange={(event) => setBackupEmail(event.target.value)} />
|
||||
<Input placeholder="Основной телефон" value={phone} onChange={(event) => setPhone(event.target.value)} />
|
||||
<Input placeholder="Резервный телефон" value={backupPhone} onChange={(event) => setBackupPhone(event.target.value)} />
|
||||
</div>
|
||||
<Button className="mt-4" onClick={updateProfile} disabled={isSaving}>
|
||||
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-medium">Документы</h2>
|
||||
<p className="mt-1 text-sm text-[#667085]">ID бережно хранит документы и подставляет их там, где вы разрешили.</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => router.push('/documents')}>Все документы</Button>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-4 gap-3">
|
||||
<ActionTile icon={FileText} label="Паспорт РФ" onClick={() => openDocument('PASSPORT_RF')} />
|
||||
<ActionTile icon={FileText} label="Загран" onClick={() => openDocument('FOREIGN_PASSPORT')} />
|
||||
<ActionTile icon={Car} label="ВУ" onClick={() => openDocument('DRIVER_LICENSE')} />
|
||||
<ActionTile icon={FileText} label="ОМС" onClick={() => openDocument('OMS')} />
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||
{documents.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-[#667085]">Документы пока не добавлены</p>
|
||||
) : (
|
||||
documents.map((document) => {
|
||||
const config = getDocumentType(document.type);
|
||||
return (
|
||||
<Row
|
||||
key={document.id}
|
||||
icon={FileText}
|
||||
title={config?.label ?? document.type}
|
||||
text={document.number}
|
||||
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AddressQuickSection layout="data" isPinLocked={isPinLocked} isReady={isReady} />
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Контакты</h2>
|
||||
<Row icon={Mail} title="Email в ID" text={email || 'Не указан'} />
|
||||
<Row icon={Phone} title="Основной телефон" text={phone || 'Не указан'} />
|
||||
<Row icon={Mail} title="Запасная почта" text={backupEmail || 'Не указана'} />
|
||||
<Row icon={UserRound} title="Добавить внешние аккаунты" text="Помогут быстрее входить и заполнить данные" />
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Управление данными</h2>
|
||||
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40">
|
||||
<Row
|
||||
icon={Trash2}
|
||||
title="Удалить профиль"
|
||||
text="Аккаунт будет деактивирован, вход станет невозможен"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{activeDocumentType && user ? (
|
||||
<DocumentFormDialog
|
||||
open={Boolean(activeDocumentType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveDocumentType(null);
|
||||
}}
|
||||
documentType={activeDocumentType}
|
||||
userId={user.id}
|
||||
token={token}
|
||||
existing={documentsByType.get(activeDocumentType) ?? null}
|
||||
onSaved={loadDocuments}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Удалить профиль?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm leading-relaxed text-[#667085]">
|
||||
Ваш аккаунт будет помечен как удалённый. Вы сразу выйдете из системы и больше не сможете войти с текущими
|
||||
данными. Почта, телефон и логин будут освобождены для новой регистрации. Административные роли будут сняты.
|
||||
Запись в базе сохранится в архивном виде.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button className="flex-1 bg-red-600 hover:bg-red-700" onClick={() => void confirmDeleteProfile()} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Удаляем...
|
||||
</>
|
||||
) : (
|
||||
'Удалить профиль'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
133
apps/frontend/app/documents/page.tsx
Normal file
133
apps/frontend/app/documents/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronRight, Plus } from 'lucide-react';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import {
|
||||
DOCUMENT_CATEGORIES,
|
||||
DOCUMENT_TYPES,
|
||||
getDocumentType,
|
||||
QUICK_DOCUMENT_TYPES,
|
||||
indexDocumentsByType,
|
||||
type DocumentTypeCode
|
||||
} from '@/lib/document-catalog';
|
||||
import { apiFetch, getApiErrorMessage, UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||
const [activeType, setActiveType] = useState<DocumentTypeCode | null>(null);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
try {
|
||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
|
||||
setDocuments(response.documents ?? []);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
|
||||
if (message) showToast(message);
|
||||
}
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !user || isPinLocked) return;
|
||||
void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||||
|
||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||
|
||||
function openCreate(type: DocumentTypeCode) {
|
||||
setActiveType(type);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/documents" wide>
|
||||
<h1 className="text-3xl font-medium tracking-tight">Документы</h1>
|
||||
<p className="mt-1 text-sm text-[#667085]">Храните документы и заполняйте формы — ID подставит данные там, где вы разрешите.</p>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-[28px] bg-[linear-gradient(135deg,#fff4f4,#fff8ef)] p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-lg font-medium">Здесь будут ваши документы</p>
|
||||
<p className="mt-1 text-sm text-[#667085]">Добавьте паспорт, права или полис — данные сохранятся в зашифрованном виде.</p>
|
||||
</div>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-white text-3xl shadow-sm">🛂</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
{QUICK_DOCUMENT_TYPES.map((typeCode) => {
|
||||
const config = getDocumentType(typeCode);
|
||||
if (!config) return null;
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={typeCode}
|
||||
type="button"
|
||||
onClick={() => openCreate(typeCode)}
|
||||
className="flex min-h-[110px] flex-col items-center justify-center gap-2 rounded-[22px] bg-[#f4f5f8] p-3 text-center transition hover:bg-[#eceef4]"
|
||||
>
|
||||
<div className={cn('flex h-12 w-12 items-center justify-center rounded-2xl', config.accent)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-[12px] font-medium leading-tight">Добавить {config.shortLabel.toLowerCase()}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{DOCUMENT_CATEGORIES.map((category) => (
|
||||
<section key={category.id} className="mt-8">
|
||||
<h2 className="mb-3 text-xl font-medium">{category.title}</h2>
|
||||
<div className="overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||
{DOCUMENT_TYPES.filter((item) => item.category === category.id).map((item) => {
|
||||
const Icon = item.icon;
|
||||
const existing = documentsByType.get(item.type);
|
||||
return (
|
||||
<button
|
||||
key={item.type}
|
||||
type="button"
|
||||
onClick={() => openCreate(item.type)}
|
||||
className="flex w-full items-center gap-4 border-b border-white/70 px-4 py-4 text-left last:border-b-0 hover:bg-white/60"
|
||||
>
|
||||
<div className={cn('flex h-11 w-11 items-center justify-center rounded-2xl', item.accent)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{item.label}</div>
|
||||
{existing ? <div className="truncate text-sm text-[#667085]">{existing.number}</div> : null}
|
||||
</div>
|
||||
{existing ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : <Plus className="h-5 w-5 text-[#a8adbc]" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{activeType ? (
|
||||
<DocumentFormDialog
|
||||
open={Boolean(activeType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveType(null);
|
||||
}}
|
||||
documentType={activeType}
|
||||
userId={user?.id ?? ''}
|
||||
token={token}
|
||||
existing={documentsByType.get(activeType) ?? null}
|
||||
onSaved={loadDocuments}
|
||||
/>
|
||||
) : null}
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
14
apps/frontend/app/family/[groupId]/page.tsx
Normal file
14
apps/frontend/app/family/[groupId]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { FamilyGroupView } from '@/components/family/family-group-view';
|
||||
|
||||
export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) {
|
||||
const { groupId } = use(params);
|
||||
return (
|
||||
<IdShell active="/family" wide>
|
||||
<FamilyGroupView groupId={groupId} />
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
97
apps/frontend/app/family/page.tsx
Normal file
97
apps/frontend/app/family/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronRight, Heart, Plus, UsersRound } from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
import { apiFetch, FamilyGroup, fetchFamilyGroups, getApiErrorMessage } from '@/lib/api';
|
||||
|
||||
export default function FamilyPage() {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [groups, setGroups] = useState<FamilyGroup[]>([]);
|
||||
const [name, setName] = useState('Моя семья');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
fetchFamilyGroups(user.id, token)
|
||||
.then((response) => setGroups(response.groups ?? []))
|
||||
.catch((error) => {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить семью');
|
||||
if (message) showToast(message);
|
||||
});
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
|
||||
async function createGroup() {
|
||||
if (!user || !token) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const group = await apiFetch<FamilyGroup>('/family/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ownerId: user.id, name })
|
||||
}, token);
|
||||
router.push(`/family/${group.id}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось создать семью');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<IdShell active="/family">
|
||||
<div className="py-20 text-center text-[#667085]">Загрузка...</div>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/family" wide>
|
||||
<p className="text-sm text-[#667085]">Семья</p>
|
||||
<h1 className="text-4xl font-medium tracking-tight">Семейный доступ</h1>
|
||||
<p className="mt-2 text-[#667085]">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
|
||||
|
||||
<div className="mt-8 flex gap-3">
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Название семьи" />
|
||||
<Button onClick={() => void createGroup()} disabled={creating}>
|
||||
{creating ? 'Создаём...' : (<><Plus className="h-4 w-4" />Создать</>)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
{groups.length ? groups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/family/${group.id}`)}
|
||||
className="flex w-full items-center gap-4 rounded-[24px] bg-[#f4f5f8] p-5 text-left transition hover:bg-[#eceef4]"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white">
|
||||
<Heart className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold">{group.name}</h2>
|
||||
<p className="text-sm text-[#667085]">{(group.members ?? []).length} участников</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rounded-[24px] bg-[#f4f5f8] p-5 text-[#667085]">
|
||||
<UsersRound className="mb-3 h-6 w-6" />
|
||||
Семейных групп пока нет
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
45
apps/frontend/app/globals.css
Normal file
45
apps/frontend/app/globals.css
Normal file
@@ -0,0 +1,45 @@
|
||||
@import "tailwindcss";
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #111827;
|
||||
--muted: #f4f5f8;
|
||||
--muted-foreground: #667085;
|
||||
--border: #e6e8ef;
|
||||
--primary: #20212b;
|
||||
--primary-foreground: #ffffff;
|
||||
--radius: 1.25rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.soft-card {
|
||||
border-radius: 24px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.id-shadow {
|
||||
box-shadow: 0 24px 70px rgb(22 26 43 / 12%);
|
||||
}
|
||||
28
apps/frontend/app/icon.tsx
Normal file
28
apps/frontend/app/icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const size = { width: 32, height: 32 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Icon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #6f58ff, #ffd2a8)',
|
||||
borderRadius: 8,
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
ID
|
||||
</div>
|
||||
),
|
||||
size
|
||||
);
|
||||
}
|
||||
18
apps/frontend/app/layout.tsx
Normal file
18
apps/frontend/app/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Providers } from './providers';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'MVK ID',
|
||||
description: 'Единый аккаунт для сервисов Lendry'
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
137
apps/frontend/app/page.tsx
Normal file
137
apps/frontend/app/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BadgeCheck, Bell, Car, FileText, Fingerprint, Heart, KeyRound, PawPrint, ShieldCheck, Smartphone, UserRound } from 'lucide-react';
|
||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { ActionTile, SectionTitle } from '@/components/id/action-tile';
|
||||
import { AdminBadge } from '@/components/id/admin-badge';
|
||||
import { AvatarUpload } from '@/components/id/avatar-upload';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
import { type DocumentTypeCode, indexDocumentsByType } from '@/lib/document-catalog';
|
||||
import { apiFetch, UserDocument } from '@/lib/api';
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const { user, token, refreshProfile, isPinLocked } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const contactLine = [user?.phone, user?.username ?? user?.email].filter(Boolean).join(' · ');
|
||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
try {
|
||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
|
||||
setDocuments(response.documents ?? []);
|
||||
} catch {
|
||||
setDocuments([]);
|
||||
}
|
||||
}, [isPinLocked, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && user && !isPinLocked) void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||||
|
||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||
|
||||
function openDocument(type: DocumentTypeCode) {
|
||||
setActiveDocumentType(type);
|
||||
}
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<IdShell active="/" wide>
|
||||
<div className="py-20 text-center text-[#667085]">Загрузка профиля...</div>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/" wide>
|
||||
<div className="flex flex-col items-center">
|
||||
{user ? (
|
||||
<AvatarUpload
|
||||
userId={user.id}
|
||||
displayName={user.displayName}
|
||||
hasAvatar={user.hasAvatar}
|
||||
token={token}
|
||||
onUpdated={refreshProfile}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-24 w-24 rounded-full bg-[#f4f5f8]" />
|
||||
)}
|
||||
<h1 className="mt-4 text-2xl font-semibold">{user?.displayName ?? 'Загрузка профиля...'}</h1>
|
||||
{user ? <AdminBadge user={user} className="mt-2" /> : null}
|
||||
<p className="text-sm text-[#667085]">{contactLine || 'Данные профиля загружаются'}</p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => router.push('/family')}>
|
||||
<Heart className="h-4 w-4 text-red-500" />
|
||||
Семья
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => router.push('/data')}>
|
||||
<UserRound className="h-4 w-4" />
|
||||
Профиль
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex w-full max-w-[450px] items-center justify-between rounded-[24px] bg-[#f0f2f7] p-5">
|
||||
<div>
|
||||
<h2 className="font-semibold">Защитите ваш аккаунт</h2>
|
||||
<p className="mt-1 text-sm text-[#667085]">Настройте PIN-код и способы восстановления</p>
|
||||
<Button className="mt-4 rounded-xl" size="sm" onClick={() => router.push('/security')}>
|
||||
Перейти к защите
|
||||
</Button>
|
||||
</div>
|
||||
<BadgeCheck className="h-20 w-20 text-[#68b8ff]" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[720px]">
|
||||
<SectionTitle>Защита аккаунта</SectionTitle>
|
||||
<div className="grid grid-cols-3 gap-4 md:grid-cols-5">
|
||||
<ActionTile icon={ShieldCheck} label="Способы входа" onClick={() => router.push('/security')} />
|
||||
<ActionTile icon={Smartphone} label="Проверить устройства" onClick={() => router.push('/security')} />
|
||||
<ActionTile icon={Bell} label="Активность" onClick={() => router.push('/security')} />
|
||||
<ActionTile icon={KeyRound} label="Добавить запасную почту" onClick={() => router.push('/security')} />
|
||||
<ActionTile icon={Fingerprint} label="Привязать лицо или отпечаток" onClick={() => router.push('/security')} />
|
||||
</div>
|
||||
|
||||
<SectionTitle>Документы</SectionTitle>
|
||||
<div className="grid grid-cols-3 gap-4 md:grid-cols-6">
|
||||
<ActionTile icon={FileText} label="Все" onClick={() => router.push('/documents')} />
|
||||
<ActionTile icon={FileText} label="Паспорт" onClick={() => openDocument('PASSPORT_RF')} />
|
||||
<ActionTile icon={FileText} label="Загран" onClick={() => openDocument('FOREIGN_PASSPORT')} />
|
||||
<ActionTile icon={Car} label="ВУ" onClick={() => openDocument('DRIVER_LICENSE')} />
|
||||
</div>
|
||||
|
||||
<AddressQuickSection layout="home" isPinLocked={isPinLocked} isReady={isReady} />
|
||||
|
||||
<SectionTitle>Семья</SectionTitle>
|
||||
<div className="grid grid-cols-3 gap-4 md:grid-cols-6">
|
||||
<ActionTile icon={UserRound} label="Вы" onClick={() => router.push('/family')} />
|
||||
<ActionTile icon={Heart} label="Добавить" onClick={() => router.push('/family')} />
|
||||
<ActionTile icon={PawPrint} label="Питомцы" onClick={() => router.push('/family')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeDocumentType && user ? (
|
||||
<DocumentFormDialog
|
||||
open={Boolean(activeDocumentType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveDocumentType(null);
|
||||
}}
|
||||
documentType={activeDocumentType}
|
||||
userId={user.id}
|
||||
token={token}
|
||||
existing={documentsByType.get(activeDocumentType) ?? null}
|
||||
onSaved={loadDocuments}
|
||||
/>
|
||||
) : null}
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
20
apps/frontend/app/providers.tsx
Normal file
20
apps/frontend/app/providers.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { AuthProvider } from '@/components/id/auth-provider';
|
||||
import { RealtimeProvider } from '@/components/notifications/realtime-provider';
|
||||
import { PublicSettingsProvider } from '@/components/id/public-settings-provider';
|
||||
import { ProjectHead } from '@/components/id/project-head';
|
||||
import { AppToastProvider } from '@/components/id/toast-provider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AppToastProvider>
|
||||
<PublicSettingsProvider>
|
||||
<ProjectHead />
|
||||
<AuthProvider>
|
||||
<RealtimeProvider>{children}</RealtimeProvider>
|
||||
</AuthProvider>
|
||||
</PublicSettingsProvider>
|
||||
</AppToastProvider>
|
||||
);
|
||||
}
|
||||
277
apps/frontend/app/security/page.tsx
Normal file
277
apps/frontend/app/security/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
Laptop,
|
||||
LogOut,
|
||||
Mail,
|
||||
Monitor,
|
||||
type LucideIcon,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Speaker,
|
||||
Tv
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
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 { ActiveDevice, apiFetch, SignInEvent } from '@/lib/api';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
const deviceIcons: Record<string, LucideIcon> = {
|
||||
WEB: Monitor,
|
||||
DESKTOP: Monitor,
|
||||
IOS: Smartphone,
|
||||
ANDROID: Smartphone,
|
||||
TV: Tv,
|
||||
SMART_SPEAKER: Speaker,
|
||||
OTHER: Laptop
|
||||
};
|
||||
|
||||
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | null;
|
||||
|
||||
const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placeholder: string; type: string }> = {
|
||||
pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр', type: 'password' },
|
||||
password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' },
|
||||
backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' },
|
||||
backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' }
|
||||
};
|
||||
|
||||
export default function SecurityPage() {
|
||||
const router = useRouter();
|
||||
const { user, token, logout } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [devices, setDevices] = useState<ActiveDevice[]>([]);
|
||||
const [history, setHistory] = useState<SignInEvent[]>([]);
|
||||
const [isSecurityLoading, setIsSecurityLoading] = useState(true);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [dialogValue, setDialogValue] = useState('');
|
||||
const [isDialogSaving, setIsDialogSaving] = useState(false);
|
||||
|
||||
const loadSecurity = useCallback(async () => {
|
||||
if (!user || !token) return;
|
||||
setIsSecurityLoading(true);
|
||||
try {
|
||||
const [devicesResponse, historyResponse] = await Promise.all([
|
||||
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token)
|
||||
]);
|
||||
setDevices(devicesResponse.devices ?? []);
|
||||
setHistory(historyResponse.events ?? []);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
|
||||
} finally {
|
||||
setIsSecurityLoading(false);
|
||||
}
|
||||
}, [showToast, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !user) return;
|
||||
void loadSecurity();
|
||||
}, [isReady, loadSecurity, user]);
|
||||
|
||||
async function revokeDevice(deviceId: string) {
|
||||
if (!user || !token) return;
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/devices/${deviceId}/revoke`, { method: 'POST' }, token);
|
||||
setDevices((current) => current.filter((device) => device.id !== deviceId));
|
||||
showToast('Сессии устройства завершены');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось завершить сессию');
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAllSessions() {
|
||||
if (!user || !token) return;
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/revoke-all-sessions`, { method: 'POST' }, token);
|
||||
showToast('Все сессии завершены');
|
||||
logout();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось выйти везде');
|
||||
} finally {
|
||||
setIsRevoking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDialog() {
|
||||
if (!user || !token || !dialogMode) return;
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
if (dialogMode === 'pin') {
|
||||
await apiFetch(`/security/users/${user.id}/pin/setup`, { method: 'POST', body: JSON.stringify({ pin: dialogValue.trim() }) }, token);
|
||||
showToast('PIN-код установлен');
|
||||
} else if (dialogMode === 'password') {
|
||||
await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token);
|
||||
showToast('Пароль установлен');
|
||||
} else if (dialogMode === 'backupEmail') {
|
||||
await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupEmail: dialogValue.trim() }) }, token);
|
||||
showToast('Резервная почта добавлена');
|
||||
} else if (dialogMode === 'backupPhone') {
|
||||
await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupPhone: dialogValue.trim() }) }, token);
|
||||
showToast('Резервный телефон добавлен');
|
||||
}
|
||||
setDialogMode(null);
|
||||
setDialogValue('');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось сохранить');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(mode: Exclude<DialogMode, null>) {
|
||||
setDialogValue('');
|
||||
setDialogMode(mode);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/security">
|
||||
<p className="text-sm text-[#667085]">Безопасность</p>
|
||||
<h1 className="text-4xl font-medium tracking-tight">Устройства</h1>
|
||||
<p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p>
|
||||
|
||||
<div className="mt-8 space-y-2">
|
||||
{isSecurityLoading ? (
|
||||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
|
||||
) : devices.length ? (
|
||||
devices.map((device) => {
|
||||
const Icon = deviceIcons[device.type] ?? Laptop;
|
||||
return (
|
||||
<div key={device.id} className="flex items-center gap-4 rounded-[20px] bg-[#f4f5f8] px-4 py-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{device.name}</span>
|
||||
<span className="text-xs text-[#667085]">{device.lastIp ?? 'IP не сохранён'} · {formatDate(device.lastSeenAt)}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="shrink-0 text-[#d14343] hover:bg-[#fbeaea]" onClick={() => revokeDevice(device.id)}>
|
||||
Выйти
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Активных устройств пока нет</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-1">
|
||||
<button type="button" onClick={revokeAllSessions} disabled={isRevoking || !user} className="flex w-full items-center gap-4 border-t border-[#eceef4] py-4 text-left disabled:opacity-60">
|
||||
<LogOut className="h-6 w-6" />
|
||||
<span>{isRevoking ? 'Выходим...' : 'Выйти везде'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Способ входа в профиль</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
<button type="button" onClick={() => openDialog('pin')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
|
||||
<Fingerprint className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">PIN-код</p>
|
||||
<p className="text-sm text-[#667085]">Установить или изменить PIN для входа</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
<button type="button" onClick={() => openDialog('password')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Пароль</p>
|
||||
<p className="text-sm text-[#667085]">{user?.email || user?.phone ? 'Добавить пароль как способ входа' : 'Установить пароль'}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Способы восстановления</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
<button type="button" onClick={() => openDialog('backupEmail')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
|
||||
<Mail className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Резервная почта</p>
|
||||
<p className="text-sm text-[#667085]">{user?.backupEmail || 'Не добавлена'}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
<button type="button" onClick={() => openDialog('backupPhone')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
|
||||
<Phone className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Резервный телефон</p>
|
||||
<p className="text-sm text-[#667085]">{user?.backupPhone || 'Не добавлен'}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Активность</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{isSecurityLoading ? (
|
||||
<div className="text-[#667085]">Загружаем историю входов...</div>
|
||||
) : history.length ? (
|
||||
history.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-4 border-b border-[#eceef4] pb-3">
|
||||
<Clock3 className="h-5 w-5" />
|
||||
<span className="flex-1">{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</span>
|
||||
<span className="text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[#667085]">История входов пока пуста</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(open) => (open ? null : setDialogMode(null))}>
|
||||
<DialogContent>
|
||||
{dialogMode ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<ShieldCheck className="h-5 w-5 text-[#667085]" />
|
||||
<Input
|
||||
className="border-0 bg-transparent focus:ring-0"
|
||||
type={dialogConfig[dialogMode].type}
|
||||
placeholder={dialogConfig[dialogMode].placeholder}
|
||||
value={dialogValue}
|
||||
onChange={(event) => setDialogValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
|
||||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user