first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View 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;
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function AdminIndexPage() {
redirect('/admin/users');
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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%);
}

View 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
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}