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

42
apps/frontend/Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1.4
FROM node:24-alpine
WORKDIR /app
ARG NPM_REGISTRY=https://registry.npmjs.org
ARG NEXT_PUBLIC_API_URL=http://localhost:3000
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8085/ws
COPY package.json package-lock.json .npmrc ./
COPY apps/sso-core/package.json ./apps/sso-core/
COPY apps/api-gateway/package.json ./apps/api-gateway/
COPY apps/frontend/package.json ./apps/frontend/
COPY apps/docs/package.json ./apps/docs/
RUN --mount=type=cache,target=/root/.npm \
npm config set registry "${NPM_REGISTRY}" && \
for i in 1 2 3 4 5; do \
echo "npm ci: попытка $i/5" && \
npm ci --no-audit --no-fund && exit 0; \
echo "npm ci: ошибка сети, повтор через $((i * 15)) сек..."; \
sleep $((i * 15)); \
done; \
echo "npm ci: все попытки исчерпаны"; \
exit 1
COPY apps ./apps
COPY shared ./shared
COPY tsconfig.base.json ./
ENV NEXT_TELEMETRY_DISABLED="1"
ENV PORT="3000"
ENV HOSTNAME="0.0.0.0"
ENV NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}"
ENV NEXT_PUBLIC_WS_URL="${NEXT_PUBLIC_WS_URL}"
RUN npm --workspace @lendry/frontend run build
EXPOSE 3000
CMD ["npm", "--workspace", "@lendry/frontend", "run", "start"]

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

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,299 @@
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import { Loader2, MapPin, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/id/toast-provider';
import { ADDRESS_LABELS, type AddressLabel } from '@/lib/address-catalog';
import { forwardGeocode, reverseGeocode } from '@/lib/geocoding';
import { deleteUserAddress, getApiErrorMessage, upsertUserAddress, UserAddress } from '@/lib/api';
import { cn } from '@/lib/utils';
const AddressMapPicker = dynamic(
() => import('@/components/addresses/address-map-picker').then((module) => module.AddressMapPicker),
{
ssr: false,
loading: () => (
<div className="flex h-[280px] items-center justify-center rounded-2xl border border-[#eceef4] bg-[#fafbfd] text-sm text-[#667085]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка карты...
</div>
)
}
);
function FieldLabel({ children }: { children: React.ReactNode }) {
return <label className="block text-sm font-medium text-[#1f2430]">{children}</label>;
}
export function AddressFormDialog({
open,
onOpenChange,
label,
userId,
token,
existing,
onSaved
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
label: AddressLabel;
userId: string;
token?: string | null;
existing: UserAddress | null;
onSaved: () => void;
}) {
const { showToast } = useToast();
const [mode, setMode] = useState<'map' | 'manual'>('map');
const [city, setCity] = useState('');
const [street, setStreet] = useState('');
const [house, setHouse] = useState('');
const [apartment, setApartment] = useState('');
const [comment, setComment] = useState('');
const [fullAddress, setFullAddress] = useState('');
const [latitude, setLatitude] = useState<number | undefined>();
const [longitude, setLongitude] = useState<number | undefined>();
const [isGeocoding, setIsGeocoding] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open) return;
setMode(existing?.latitude != null && existing?.longitude != null ? 'map' : existing ? 'manual' : 'map');
setCity(existing?.city ?? '');
setStreet(existing?.street ?? '');
setHouse(existing?.house ?? '');
setApartment(existing?.apartment ?? '');
setComment(existing?.comment ?? '');
setFullAddress(existing?.fullAddress ?? '');
setLatitude(existing?.latitude);
setLongitude(existing?.longitude);
}, [existing, open]);
useEffect(() => {
return () => {
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
};
}, []);
async function handleMapPick(lat: number, lng: number) {
setLatitude(lat);
setLongitude(lng);
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
geocodeTimer.current = setTimeout(async () => {
setIsGeocoding(true);
try {
const parsed = await reverseGeocode(lat, lng);
if (parsed) {
setCity(parsed.city);
setStreet(parsed.street);
setHouse(parsed.house);
setFullAddress(parsed.fullAddress);
}
} finally {
setIsGeocoding(false);
}
}, 450);
}
async function locateManualAddress() {
const query = fullAddress.trim() || [`г. ${city}`, `${street}, ${house}`].filter(Boolean).join(', ');
if (!query.trim()) {
showToast('Сначала укажите адрес для поиска');
return;
}
setIsGeocoding(true);
try {
const result = await forwardGeocode(query);
if (!result) {
showToast('Адрес не найден на карте');
return;
}
setLatitude(result.lat);
setLongitude(result.lng);
setCity(result.parsed.city || city);
setStreet(result.parsed.street || street);
setHouse(result.parsed.house || house);
setFullAddress(result.parsed.fullAddress);
setMode('map');
} finally {
setIsGeocoding(false);
}
}
async function saveAddress() {
setIsSaving(true);
try {
await upsertUserAddress(
userId,
{
addressId: existing?.id,
label,
city: city.trim(),
street: street.trim(),
house: house.trim(),
apartment: apartment.trim() || undefined,
comment: comment.trim() || undefined,
latitude,
longitude,
fullAddress: fullAddress.trim() || undefined
},
token
);
showToast(existing ? 'Адрес обновлён' : 'Адрес добавлен');
onSaved();
onOpenChange(false);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось сохранить адрес');
if (message) showToast(message);
} finally {
setIsSaving(false);
}
}
async function removeAddress() {
if (!existing) return;
setIsDeleting(true);
try {
await deleteUserAddress(userId, existing.id, token);
showToast('Адрес удалён');
onSaved();
onOpenChange(false);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось удалить адрес');
if (message) showToast(message);
} finally {
setIsDeleting(false);
}
}
const title = existing
? `Редактировать: ${ADDRESS_LABELS[label].title}`
: `Добавить: ${ADDRESS_LABELS[label].title}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[92vh] overflow-y-auto rounded-[28px] sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<Tabs value={mode} onValueChange={(value) => setMode(value as 'map' | 'manual')}>
<TabsList className="grid w-full grid-cols-2 bg-[#f4f5f8] p-1">
<TabsTrigger
value="map"
className={cn(
'rounded-xl px-4 py-2 text-sm data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm',
'text-[#667085]'
)}
>
На карте
</TabsTrigger>
<TabsTrigger
value="manual"
className={cn(
'rounded-xl px-4 py-2 text-sm data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm',
'text-[#667085]'
)}
>
Вручную
</TabsTrigger>
</TabsList>
<TabsContent value="map" className="space-y-4">
<AddressMapPicker latitude={latitude} longitude={longitude} onPick={handleMapPick} />
{isGeocoding ? (
<div className="flex items-center gap-2 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Определяем адрес...
</div>
) : fullAddress ? (
<div className="flex items-start gap-2 rounded-2xl bg-[#f4f5f8] px-4 py-3 text-sm">
<MapPin className="mt-0.5 h-4 w-4 shrink-0 text-[#667085]" />
<span>{fullAddress}</span>
</div>
) : (
<p className="text-sm text-[#667085]">Выберите точку на карте адрес подставится автоматически</p>
)}
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<div className="space-y-2">
<FieldLabel>Полный адрес</FieldLabel>
<Input
placeholder="Например: г. Москва, ул. Тверская, 1"
value={fullAddress}
onChange={(event) => setFullAddress(event.target.value)}
/>
</div>
<Button type="button" variant="secondary" className="w-full" onClick={() => void locateManualAddress()} disabled={isGeocoding}>
{isGeocoding ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ищем на карте...
</>
) : (
'Найти на карте'
)}
</Button>
</TabsContent>
</Tabs>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<FieldLabel>Город</FieldLabel>
<Input placeholder="Москва" value={city} onChange={(event) => setCity(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Улица</FieldLabel>
<Input placeholder="Тверская" value={street} onChange={(event) => setStreet(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Дом</FieldLabel>
<Input placeholder="1" value={house} onChange={(event) => setHouse(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Квартира</FieldLabel>
<Input placeholder="Необязательно" value={apartment} onChange={(event) => setApartment(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Комментарий</FieldLabel>
<Input placeholder="Подъезд, домофон..." value={comment} onChange={(event) => setComment(event.target.value)} />
</div>
</div>
<div className="mt-6 flex gap-3">
{existing ? (
<Button
type="button"
variant="secondary"
className="text-red-600 hover:text-red-700"
onClick={() => void removeAddress()}
disabled={isSaving || isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
) : null}
<Button type="button" variant="secondary" className="flex-1" onClick={() => onOpenChange(false)} disabled={isSaving || isDeleting}>
Отмена
</Button>
<Button type="button" className="flex-1" onClick={() => void saveAddress()} disabled={isSaving || isDeleting || isGeocoding}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Сохраняем...
</>
) : (
'Сохранить'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useEffect, useRef } from 'react';
import L from 'leaflet';
const DEFAULT_CENTER = { lat: 55.7558, lng: 37.6173 };
let iconsFixed = false;
function fixLeafletIcons() {
if (iconsFixed || typeof window === 'undefined') return;
iconsFixed = true;
L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
}
export function AddressMapPicker({
latitude,
longitude,
onPick
}: {
latitude?: number;
longitude?: number;
onPick: (lat: number, lng: number) => void;
}) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<L.Map | null>(null);
const markerRef = useRef<L.Marker | null>(null);
const onPickRef = useRef(onPick);
useEffect(() => {
onPickRef.current = onPick;
}, [onPick]);
useEffect(() => {
fixLeafletIcons();
if (!mapRef.current || mapInstance.current) return;
const lat = latitude ?? DEFAULT_CENTER.lat;
const lng = longitude ?? DEFAULT_CENTER.lng;
const map = L.map(mapRef.current, { zoomControl: true }).setView([lat, lng], latitude != null ? 16 : 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
const marker = L.marker([lat, lng], { draggable: true }).addTo(map);
const emitPick = (nextLat: number, nextLng: number) => {
onPickRef.current(nextLat, nextLng);
};
marker.on('dragend', () => {
const position = marker.getLatLng();
emitPick(position.lat, position.lng);
});
map.on('click', (event) => {
marker.setLatLng(event.latlng);
emitPick(event.latlng.lat, event.latlng.lng);
});
mapInstance.current = map;
markerRef.current = marker;
const resizeObserver = new ResizeObserver(() => {
map.invalidateSize();
});
resizeObserver.observe(mapRef.current);
return () => {
resizeObserver.disconnect();
map.remove();
mapInstance.current = null;
markerRef.current = null;
};
}, []);
useEffect(() => {
if (!markerRef.current || !mapInstance.current) return;
if (latitude == null || longitude == null) return;
const latLng = L.latLng(latitude, longitude);
markerRef.current.setLatLng(latLng);
mapInstance.current.setView(latLng, Math.max(mapInstance.current.getZoom(), 15));
}, [latitude, longitude]);
return (
<div className="overflow-hidden rounded-2xl border border-[#eceef4]">
<div ref={mapRef} className="h-[280px] w-full" />
<p className="border-t border-[#eceef4] bg-[#fafbfd] px-4 py-2 text-xs text-[#667085]">
Нажмите на карту или перетащите маркер, чтобы указать адрес
</p>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronRight, Home, MapPin } from 'lucide-react';
import { AddressFormDialog } from '@/components/addresses/address-form-dialog';
import { ActionTile, SectionTitle } from '@/components/id/action-tile';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import {
ADDRESS_LABELS,
formatAddressLine,
getOtherAddresses,
getPrimaryAddress,
type AddressLabel
} from '@/lib/address-catalog';
import { fetchUserAddresses, getApiErrorMessage, UserAddress } from '@/lib/api';
function AddressRow({ title, text, onClick }: { title: string; text: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-4 border-b border-[#eceef4] px-4 py-4 text-left transition last:border-b-0 hover:bg-[#fafbfd]"
>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
<MapPin className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium">{title}</div>
<div className="truncate text-sm text-[#667085]">{text}</div>
</div>
<ChevronRight className="h-5 w-5 shrink-0 text-[#a8adbc]" />
</button>
);
}
export function AddressQuickSection({
layout,
isPinLocked = false,
isReady = true
}: {
layout: 'home' | 'data';
isPinLocked?: boolean;
isReady?: boolean;
}) {
const { user, token } = useAuth();
const { showToast } = useToast();
const [addresses, setAddresses] = useState<UserAddress[]>([]);
const [formOpen, setFormOpen] = useState(false);
const [activeLabel, setActiveLabel] = useState<AddressLabel>('HOME');
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null);
const loadAddresses = useCallback(async () => {
if (!user || !token || isPinLocked) return;
try {
const response = await fetchUserAddresses(user.id, token);
setAddresses(response.addresses ?? []);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить адреса');
if (message) showToast(message);
}
}, [isPinLocked, showToast, token, user]);
useEffect(() => {
if (isReady && user && !isPinLocked) void loadAddresses();
}, [isPinLocked, isReady, loadAddresses, user]);
const homeAddress = useMemo(() => getPrimaryAddress(addresses, 'HOME') as UserAddress | null, [addresses]);
const workAddress = useMemo(() => getPrimaryAddress(addresses, 'WORK') as UserAddress | null, [addresses]);
const otherAddresses = useMemo(() => getOtherAddresses(addresses), [addresses]);
function openAddress(label: AddressLabel, existing: UserAddress | null = null) {
setActiveLabel(label);
if (label === 'HOME') setEditingAddress(existing ?? homeAddress);
else if (label === 'WORK') setEditingAddress(existing ?? workAddress);
else setEditingAddress(existing);
setFormOpen(true);
}
const tiles = (
<div className={layout === 'home' ? 'grid grid-cols-3 gap-4' : 'mt-4 grid grid-cols-3 gap-3'}>
<ActionTile
icon={Home}
label={ADDRESS_LABELS.HOME.title}
description={homeAddress ? formatAddressLine(homeAddress) : layout === 'data' ? 'Не указан' : undefined}
className={layout === 'home' ? 'min-h-[76px]' : 'min-h-[76px]'}
onClick={() => openAddress('HOME')}
/>
<ActionTile
icon={MapPin}
label={ADDRESS_LABELS.WORK.title}
description={workAddress ? formatAddressLine(workAddress) : layout === 'data' ? 'Не указан' : undefined}
className="min-h-[76px]"
onClick={() => openAddress('WORK')}
/>
<ActionTile
icon={MapPin}
label={ADDRESS_LABELS.OTHER.shortTitle}
description={
otherAddresses.length > 0
? `${otherAddresses.length} ${otherAddresses.length === 1 ? 'адрес' : otherAddresses.length < 5 ? 'адреса' : 'адресов'}`
: layout === 'data'
? 'Добавить'
: undefined
}
className="min-h-[76px]"
onClick={() => openAddress('OTHER')}
/>
</div>
);
return (
<>
{layout === 'home' ? (
<>
<SectionTitle>Адреса</SectionTitle>
{tiles}
</>
) : (
<section className="mt-10">
<h2 className="text-2xl font-medium">Адреса</h2>
<p className="mt-1 text-sm text-[#667085]">Укажите адреса на карте или введите их вручную.</p>
{tiles}
<div className="mt-4 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
{addresses.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-[#667085]">Адреса пока не добавлены</p>
) : (
addresses.map((address) => (
<AddressRow
key={address.id}
title={ADDRESS_LABELS[address.label as AddressLabel]?.title ?? address.label}
text={formatAddressLine(address)}
onClick={() => openAddress(address.label as AddressLabel, address)}
/>
))
)}
</div>
</section>
)}
{user ? (
<AddressFormDialog
open={formOpen}
onOpenChange={setFormOpen}
label={activeLabel}
userId={user.id}
token={token}
existing={editingAddress}
onSaved={loadAddresses}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,502 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Camera, Loader2, X } from 'lucide-react';
import { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/id/toast-provider';
import {
buildDocumentNumber,
DOCUMENT_TYPES,
DocumentTypeDef,
LICENSE_CATEGORIES,
parseDocumentPhotos,
parseMetadata,
withDocumentPhotos,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, UserDocument } from '@/lib/api';
import { cn } from '@/lib/utils';
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt: string;
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return <label className="block text-sm font-medium text-[#1f2430]">{children}</label>;
}
function GenderToggle({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return (
<div className="flex rounded-2xl bg-[#f4f5f8] p-1">
{[
{ id: 'male', label: 'Мужской' },
{ id: 'female', label: 'Женский' }
].map((option) => (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
'flex-1 rounded-xl px-3 py-2 text-sm transition',
value === option.id ? 'bg-white font-medium shadow-sm' : 'text-[#667085]'
)}
>
{option.label}
</button>
))}
</div>
);
}
function CategoryPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const selected = new Set(value.split(',').map((item) => item.trim()).filter(Boolean));
return (
<div className="grid grid-cols-4 gap-2">
{LICENSE_CATEGORIES.map((category) => {
const active = selected.has(category);
return (
<button
key={category}
type="button"
onClick={() => {
const next = new Set(selected);
if (active) next.delete(category);
else next.add(category);
onChange(Array.from(next).join(','));
}}
className={cn(
'rounded-xl border px-2 py-2 text-sm transition',
active ? 'border-[#20212b] bg-[#20212b] text-white' : 'border-[#eceef4] bg-white text-[#667085]'
)}
>
{category}
</button>
);
})}
</div>
);
}
function buildInitialValues(config: DocumentTypeDef, existing?: UserDocument | null) {
const metadata = parseMetadata(existing?.metadataJson);
const initial: Record<string, string> = {};
for (const field of config.fields) {
const raw = metadata[field.key];
initial[field.key] = typeof raw === 'string' ? raw : '';
if (field.latinKey) {
const latinRaw = metadata[field.latinKey];
initial[field.latinKey] = typeof latinRaw === 'string' ? latinRaw : '';
}
}
if (existing?.issuedAt) initial.issuedAt = existing.issuedAt.slice(0, 10);
if (existing?.expiresAt) initial.expiresAt = existing.expiresAt.slice(0, 10);
return initial;
}
export function DocumentFormDialog({
open,
onOpenChange,
documentType,
userId,
token,
existing,
onSaved,
readOnly
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: DocumentTypeCode;
userId: string;
token: string | null;
existing?: UserDocument | null;
onSaved: () => void | Promise<void>;
readOnly?: boolean;
}) {
const config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]);
const { showToast } = useToast();
const photoInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const fieldValuesRef = useRef<Record<string, string>>({});
const draftDocumentIdRef = useRef<string | undefined>(undefined);
const photoStorageKeysRef = useRef<string[]>([]);
const wasOpenRef = useRef(false);
const [formInstanceKey, setFormInstanceKey] = useState(0);
const [pickerValues, setPickerValues] = useState<Record<string, string>>({});
const [photoStorageKeys, setPhotoStorageKeys] = useState<string[]>([]);
const [draftDocumentId, setDraftDocumentId] = useState<string | undefined>();
const [isSaving, setIsSaving] = useState(false);
const [isPhotoUploading, setIsPhotoUploading] = useState(false);
useEffect(() => {
if (!open) {
wasOpenRef.current = false;
fieldValuesRef.current = {};
draftDocumentIdRef.current = undefined;
photoStorageKeysRef.current = [];
return;
}
if (!config || wasOpenRef.current) return;
wasOpenRef.current = true;
const initial = buildInitialValues(config, existing);
const photos = parseDocumentPhotos(parseMetadata(existing?.metadataJson));
fieldValuesRef.current = { ...initial };
photoStorageKeysRef.current = photos;
draftDocumentIdRef.current = existing?.id;
setPickerValues(initial);
setPhotoStorageKeys(photos);
setDraftDocumentId(existing?.id);
setFormInstanceKey((current) => current + 1);
}, [open, config, existing?.id, existing?.metadataJson, existing?.issuedAt, existing?.expiresAt]);
useEffect(() => {
if (!open || !existing?.id || draftDocumentIdRef.current) return;
draftDocumentIdRef.current = existing.id;
setDraftDocumentId(existing.id);
}, [open, existing?.id]);
function syncField(key: string, value: string) {
fieldValuesRef.current[key] = value;
}
function setPickerField(key: string, value: string) {
syncField(key, value);
setPickerValues((current) => ({ ...current, [key]: value }));
}
function collectFormValues() {
const values = { ...fieldValuesRef.current, ...pickerValues };
if (formRef.current) {
for (const element of formRef.current.elements) {
if (element instanceof HTMLInputElement && element.name) {
if (element.type === 'checkbox') {
values[element.name] = element.checked ? 'true' : '';
} else if (element.type !== 'radio') {
values[element.name] = element.value;
}
} else if (element instanceof HTMLTextAreaElement && element.name) {
values[element.name] = element.value;
}
}
}
fieldValuesRef.current = values;
return values;
}
async function commitFocusedField() {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) {
activeElement.blur();
}
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function buildMetadataPayload(source: Record<string, string>, photos: string[]) {
const metadata: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) {
if (key !== 'issuedAt' && key !== 'expiresAt') metadata[key] = value;
}
return JSON.stringify(withDocumentPhotos(metadata, photos));
}
function buildSaveBody(currentValues: Record<string, string>, photos: string[]) {
return {
number: buildDocumentNumber(config!, currentValues),
issuedAt: currentValues.issuedAt ? new Date(currentValues.issuedAt).toISOString() : undefined,
expiresAt: currentValues.expiresAt ? new Date(currentValues.expiresAt).toISOString() : undefined,
metadataJson: buildMetadataPayload(currentValues, photos)
};
}
async function patchDocument(documentId: string, currentValues: Record<string, string>, photos: string[]) {
if (!token || !config) return;
await apiFetch(
`/documents/users/${userId}/${documentId}`,
{ method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos)) },
token
);
}
async function ensureDocumentId(currentValues: Record<string, string>, photos: string[]) {
if (draftDocumentIdRef.current) {
return { id: draftDocumentIdRef.current, created: false };
}
if (!token || !config) {
return { id: undefined, created: false };
}
const created = await apiFetch<UserDocument>(
`/documents/users/${userId}`,
{
method: 'POST',
body: JSON.stringify({
type: documentType,
...buildSaveBody(currentValues, photos)
})
},
token
);
draftDocumentIdRef.current = created.id;
setDraftDocumentId(created.id);
return { id: created.id, created: true };
}
async function uploadPhoto(file: File) {
if (!token || readOnly) return;
setIsPhotoUploading(true);
try {
await commitFocusedField();
const currentValues = collectFormValues();
const currentPhotos = photoStorageKeysRef.current;
const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos);
if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки фото');
const presigned = await apiFetch<PresignedUploadResponse>(
`/media/documents/${targetDocumentId}/photo/upload-url`,
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
token
);
const uploadResponse = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (!uploadResponse.ok) throw new Error('Не удалось загрузить фото');
const nextPhotos = [...currentPhotos, presigned.storageKey];
photoStorageKeysRef.current = nextPhotos;
setPhotoStorageKeys(nextPhotos);
await patchDocument(targetDocumentId, currentValues, nextPhotos);
if (created) {
await onSaved();
}
showToast('Фото добавлено');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить фото');
if (message) showToast(message);
} finally {
setIsPhotoUploading(false);
if (photoInputRef.current) photoInputRef.current.value = '';
}
}
async function handlePhotoChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files ?? []);
for (const file of files) {
await uploadPhoto(file);
}
}
function removePhoto(storageKey: string) {
const nextPhotos = photoStorageKeysRef.current.filter((item) => item !== storageKey);
photoStorageKeysRef.current = nextPhotos;
setPhotoStorageKeys(nextPhotos);
}
async function handleSave() {
if (!token || !config || readOnly) return;
setIsSaving(true);
try {
await commitFocusedField();
const currentValues = collectFormValues();
const photos = photoStorageKeysRef.current;
const documentId = draftDocumentIdRef.current ?? existing?.id;
if (documentId) {
await patchDocument(documentId, currentValues, photos);
showToast('Документ обновлён');
} else {
const created = await apiFetch<UserDocument>(
`/documents/users/${userId}`,
{ method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos) }) },
token
);
draftDocumentIdRef.current = created.id;
setDraftDocumentId(created.id);
showToast('Документ сохранён');
}
await onSaved();
onOpenChange(false);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось сохранить документ');
if (message) showToast(message);
} finally {
setIsSaving(false);
}
}
if (!config) return null;
const initialValues = fieldValuesRef.current;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto rounded-[28px] p-0 sm:max-w-[420px]">
<DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4">
<div className="flex items-center justify-between">
<DialogTitle className="text-xl font-semibold">{config.label}</DialogTitle>
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]">
<X className="h-4 w-4" />
</button>
</div>
</DialogHeader>
<div className="space-y-4 px-5 pb-5">
{photoStorageKeys.length > 0 ? (
<DocumentPhotoGallery
userId={userId}
token={token}
storageKeys={photoStorageKeys}
readOnly={readOnly}
onRemove={readOnly ? undefined : removePhoto}
/>
) : null}
{!readOnly ? (
<>
<button
type="button"
onClick={() => photoInputRef.current?.click()}
disabled={isPhotoUploading}
className="flex w-full items-center justify-center gap-2 rounded-[20px] bg-[#f4f5f8] px-4 py-4 text-sm font-medium transition hover:bg-[#eceef4]"
>
{isPhotoUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
{photoStorageKeys.length > 0 ? 'Добавить ещё фото' : 'Добавить фото'}
</button>
<input ref={photoInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoChange} />
</>
) : null}
<form
key={formInstanceKey}
ref={formRef}
className="space-y-3 rounded-[24px] bg-[#f4f5f8] p-4"
onSubmit={(event) => {
event.preventDefault();
void handleSave();
}}
>
{config.fields.map((field) => {
if (readOnly) {
const value = initialValues[field.key];
if (!value) return null;
return (
<div key={field.key}>
<FieldLabel>{field.label}</FieldLabel>
<p className="mt-1 text-sm text-[#1f2430]">{value}</p>
</div>
);
}
if (field.kind === 'checkbox') {
return (
<label key={field.key} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name={field.key}
defaultChecked={initialValues[field.key] === 'true'}
onChange={(event) => syncField(field.key, event.target.checked ? 'true' : '')}
/>
{field.label}
</label>
);
}
if (field.kind === 'gender') {
return (
<div key={field.key} className="space-y-2">
<FieldLabel>{field.label}</FieldLabel>
<input type="hidden" name={field.key} value={pickerValues[field.key] ?? ''} readOnly />
<GenderToggle value={pickerValues[field.key] ?? ''} onChange={(value) => setPickerField(field.key, value)} />
</div>
);
}
if (field.kind === 'categories') {
return (
<div key={field.key} className="space-y-2">
<FieldLabel>{field.label}</FieldLabel>
<input type="hidden" name={field.key} value={pickerValues[field.key] ?? ''} readOnly />
<CategoryPicker value={pickerValues[field.key] ?? ''} onChange={(value) => setPickerField(field.key, value)} />
</div>
);
}
if (field.kind === 'textarea') {
return (
<div key={field.key} className="space-y-2">
<FieldLabel>{field.label}</FieldLabel>
<textarea
name={field.key}
defaultValue={initialValues[field.key] ?? ''}
onChange={(event) => syncField(field.key, event.target.value)}
onBlur={(event) => syncField(field.key, event.target.value)}
onCompositionEnd={(event) => syncField(field.key, event.currentTarget.value)}
placeholder={field.placeholder ?? field.label}
className="min-h-[96px] w-full rounded-2xl border border-[#eceef4] bg-white px-4 py-3 text-sm outline-none focus:border-[#20212b]"
/>
</div>
);
}
return (
<div key={field.key} className="space-y-2">
<FieldLabel>{field.label}</FieldLabel>
<Input
type={field.kind === 'date' ? 'date' : 'text'}
name={field.key}
defaultValue={initialValues[field.key] ?? ''}
onChange={(event) => syncField(field.key, event.target.value)}
onBlur={(event) => syncField(field.key, event.target.value)}
onCompositionEnd={(event) => syncField(field.key, event.currentTarget.value)}
placeholder={field.placeholder ?? field.label}
readOnly={readOnly}
/>
{field.latinKey ? (
<div className="space-y-2">
<FieldLabel>{field.latinLabel ?? field.latinKey}</FieldLabel>
<Input
name={field.latinKey}
defaultValue={initialValues[field.latinKey] ?? ''}
onChange={(event) => syncField(field.latinKey!, event.target.value)}
onBlur={(event) => syncField(field.latinKey!, event.target.value)}
onCompositionEnd={(event) => syncField(field.latinKey!, event.currentTarget.value)}
placeholder={field.latinLabel ?? field.latinKey}
readOnly={readOnly}
/>
</div>
) : null}
</div>
);
})}
</form>
{!readOnly ? (
<Button
type="button"
className="w-full rounded-[18px] py-6"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void handleSave()}
disabled={isSaving}
>
{isSaving ? 'Сохраняем...' : 'Сохранить'}
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import * as React from 'react';
import { Loader2, Trash2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { fetchDocumentPhotoUrl } from '@/lib/api';
import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
interface DocumentPhotoGalleryProps {
userId: string;
token: string | null;
storageKeys: string[];
readOnly?: boolean;
onRemove?: (storageKey: string) => void;
}
export function DocumentPhotoGallery({ userId, token, storageKeys, readOnly, onRemove }: DocumentPhotoGalleryProps) {
const { isPinLocked } = useAuth();
const [urls, setUrls] = React.useState<Record<string, string>>({});
const [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(new Set());
const [viewerOpen, setViewerOpen] = React.useState(false);
const [viewerIndex, setViewerIndex] = React.useState(0);
React.useEffect(() => {
if (!token || storageKeys.length === 0 || isPinLocked) {
setUrls({});
return;
}
let cancelled = false;
setLoadingKeys(new Set(storageKeys));
void Promise.all(
storageKeys.map(async (storageKey) => {
try {
const response = await fetchDocumentPhotoUrl(userId, storageKey, token);
return { storageKey, accessUrl: response.accessUrl };
} catch {
return { storageKey, accessUrl: '' };
}
})
).then((results) => {
if (cancelled) return;
const next: Record<string, string> = {};
for (const item of results) {
if (item.accessUrl) next[item.storageKey] = item.accessUrl;
}
setUrls(next);
setLoadingKeys(new Set());
});
return () => {
cancelled = true;
};
}, [isPinLocked, storageKeys.join('|'), token, userId]);
const resolvedImages = storageKeys
.map((storageKey) => ({ storageKey, url: urls[storageKey] }))
.filter((item) => Boolean(item.url));
if (storageKeys.length === 0) return null;
return (
<>
<div className="grid grid-cols-2 gap-3">
{storageKeys.map((storageKey) => (
<div key={storageKey} className="relative">
<DocumentPhotoThumbnail
url={urls[storageKey]}
loading={loadingKeys.has(storageKey)}
onClick={() => {
const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey);
if (resolvedIndex >= 0) {
setViewerIndex(resolvedIndex);
setViewerOpen(true);
}
}}
/>
{!readOnly && onRemove ? (
<button
type="button"
onClick={() => onRemove(storageKey)}
className="absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
aria-label="Удалить фото"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
{loadingKeys.has(storageKey) ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-white/40">
<Loader2 className="h-5 w-5 animate-spin text-[#667085]" />
</div>
) : null}
</div>
))}
</div>
<DocumentFullscreenViewer
open={viewerOpen}
onOpenChange={setViewerOpen}
initialIndex={viewerIndex}
images={resolvedImages}
/>
</>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DocumentFullscreenViewerProps {
images: { storageKey: string; url: string }[];
initialIndex?: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpenChange }: DocumentFullscreenViewerProps) {
const [index, setIndex] = React.useState(initialIndex);
React.useEffect(() => {
if (open) setIndex(initialIndex);
}, [initialIndex, open]);
React.useEffect(() => {
if (!open) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onOpenChange(false);
if (event.key === 'ArrowLeft') setIndex((current) => Math.max(0, current - 1));
if (event.key === 'ArrowRight') setIndex((current) => Math.min(images.length - 1, current + 1));
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [images.length, onOpenChange, open]);
if (!open || images.length === 0) return null;
const current = images[index];
return (
<div className="fixed inset-0 z-[300] flex flex-col bg-black/95">
<div className="flex items-center justify-between px-4 py-3 text-white">
<span className="text-sm text-white/80">
{index + 1} / {images.length}
</span>
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-white/10" aria-label="Закрыть">
<X className="h-5 w-5" />
</button>
</div>
<div className="relative flex flex-1 items-center justify-center px-4 pb-6">
{images.length > 1 ? (
<button
type="button"
disabled={index === 0}
onClick={() => setIndex((value) => Math.max(0, value - 1))}
className="absolute left-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
aria-label="Предыдущее фото"
>
<ChevronLeft className="h-6 w-6" />
</button>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={current.url} alt="Фото документа" className="max-h-[calc(100vh-120px)] max-w-full object-contain" />
{images.length > 1 ? (
<button
type="button"
disabled={index === images.length - 1}
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
className="absolute right-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
aria-label="Следующее фото"
>
<ChevronRight className="h-6 w-6" />
</button>
) : null}
</div>
</div>
);
}
interface DocumentPhotoThumbnailProps {
url?: string;
loading?: boolean;
className?: string;
onClick?: () => void;
}
export function DocumentPhotoThumbnail({ url, loading, className, onClick }: DocumentPhotoThumbnailProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'group relative aspect-[4/3] overflow-hidden rounded-2xl border border-[#eceef4] bg-[#f4f5f8] transition hover:border-[#20212b]',
className
)}
>
{loading ? <div className="h-full w-full animate-pulse bg-[#eceef4]" /> : null}
{!loading && url ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt="Фото документа" className="h-full w-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition group-hover:bg-black/20">
<ZoomIn className="h-6 w-6 text-white opacity-0 transition group-hover:opacity-100" />
</div>
</>
) : null}
{!loading && !url ? <div className="flex h-full items-center justify-center text-xs text-[#667085]">Нет превью</div> : null}
</button>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FileText } from 'lucide-react';
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { getDocumentType, indexDocumentsByType, type DocumentTypeCode } from '@/lib/document-catalog';
import { apiFetch, UserDocument } from '@/lib/api';
import { cn } from '@/lib/utils';
export function UserDocumentsDialog({
open,
onOpenChange,
targetUserId,
targetUserName,
token
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
targetUserId: string;
targetUserName: string;
token: string | null;
}) {
const [documents, setDocuments] = useState<UserDocument[]>([]);
const [loading, setLoading] = useState(false);
const [activeType, setActiveType] = useState<DocumentTypeCode | null>(null);
const [selectedDocument, setSelectedDocument] = useState<UserDocument | null>(null);
const loadDocuments = useCallback(async () => {
if (!token || !targetUserId) return;
setLoading(true);
try {
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${targetUserId}`, {}, token);
setDocuments(response.documents ?? []);
} finally {
setLoading(false);
}
}, [targetUserId, token]);
useEffect(() => {
if (open) void loadDocuments();
}, [loadDocuments, open]);
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
function openDocument(type: DocumentTypeCode) {
setSelectedDocument(documentsByType.get(type) ?? null);
setActiveType(type);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto rounded-[28px] sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Документы пользователя</DialogTitle>
<p className="text-sm text-[#667085]">{targetUserName}</p>
</DialogHeader>
{loading ? <p className="py-8 text-center text-sm text-[#667085]">Загрузка документов...</p> : null}
{!loading && documents.length === 0 ? (
<p className="py-8 text-center text-sm text-[#667085]">У пользователя нет сохранённых документов</p>
) : null}
<div className="space-y-2">
{documents.map((document) => {
const config = getDocumentType(document.type);
if (!config) return null;
const Icon = config.icon;
return (
<button
key={document.id}
type="button"
onClick={() => openDocument(document.type as DocumentTypeCode)}
className="flex w-full items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4 py-3 text-left transition hover:bg-[#eceef4]"
>
<div className={cn('flex h-10 w-10 items-center justify-center rounded-xl', config.accent)}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium">{config.label}</div>
<div className="truncate text-sm text-[#667085]">{document.number}</div>
</div>
<FileText className="h-4 w-4 text-[#a8adbc]" />
</button>
);
})}
</div>
</DialogContent>
</Dialog>
{activeType ? (
<DocumentFormDialog
open={Boolean(activeType)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setActiveType(null);
setSelectedDocument(null);
}
}}
documentType={activeType}
userId={targetUserId}
token={token}
existing={selectedDocument}
onSaved={loadDocuments}
readOnly
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,805 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
BarChart3,
Camera,
FileText,
Loader2,
Mic,
Paperclip,
Plus,
Send,
Smile,
UserPlus,
Volume2,
VolumeX
} from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useRequireAuth } from '@/hooks/use-require-auth';
import {
apiFetch,
ChatMessage,
ChatRoom,
createChatRoom,
FamilyGroup,
fetchChatMessages,
fetchChatRooms,
fetchFamilyGroup,
getApiErrorMessage,
sendChatMessage,
sendFamilyInvite,
setChatRoomMuted,
updateFamilyGroup,
voteChatPoll
} from '@/lib/api';
import { cn } from '@/lib/utils';
import {
createBlobObjectUrl,
fetchAuthenticatedMediaBlob,
mediaExpiresAtMs,
revokeBlobObjectUrl,
shouldRefreshMedia,
triggerBlobDownload
} from '@/lib/authenticated-media';
import {
detectChatMessageType,
fileIconLabel,
formatFileSize,
parseMessageMetadata,
resolveChatContentType
} from '@/lib/chat-media';
const EMOJIS = ['😀', '😂', '❤️', '👍', '🎉', '🔥', '😍', '🙏', '👋', '🤔', '😢', '😎'];
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
}
interface LoadedChatMedia {
blobUrl: string;
expiresAt: number;
fileName?: string;
}
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
function FamilyAvatar({ groupId, name, hasAvatar, token }: { groupId: string; name: string; hasAvatar?: boolean; token: string | null }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!hasAvatar || !token) return;
apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token)
.then((response) => setUrl(response.accessUrl))
.catch(() => setUrl(null));
}, [groupId, hasAvatar, token]);
return (
<Avatar className="h-11 w-11">
{url ? <AvatarImage src={url} alt={name} /> : null}
<AvatarFallback>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
export function FamilyGroupView({ groupId }: { groupId: string }) {
const router = useRouter();
const { user, token } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast();
const { subscribe } = useRealtime();
const [group, setGroup] = useState<FamilyGroup | null>(null);
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [activeRoomId, setActiveRoomId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [createRoomOpen, setCreateRoomOpen] = useState(false);
const [pollOpen, setPollOpen] = useState(false);
const [inviteTarget, setInviteTarget] = useState('');
const [renameValue, setRenameValue] = useState('');
const [newRoomName, setNewRoomName] = useState('');
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState(['', '']);
const [emojiOpen, setEmojiOpen] = useState(false);
const [recording, setRecording] = useState(false);
const [mediaUrls, setMediaUrls] = useState<Record<string, LoadedChatMedia>>({});
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordingStartedAtRef = useRef<number | null>(null);
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
const avatarInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
mediaUrlsRef.current = mediaUrls;
}, [mediaUrls]);
const appendMessage = useCallback((message: ChatMessage) => {
setMessages((current) => (current.some((item) => item.id === message.id) ? current : [...current, message]));
}, []);
const activeRoom = useMemo(() => rooms.find((room) => room.id === activeRoomId) ?? null, [activeRoomId, rooms]);
const myMembership = activeRoom?.members.find((member) => member.userId === user?.id);
const loadGroup = useCallback(async () => {
if (!token || isPinLocked) return;
const [groupResponse, roomsResponse] = await Promise.all([fetchFamilyGroup(groupId, token), fetchChatRooms(groupId, token)]);
setGroup(groupResponse);
setRenameValue(groupResponse.name);
const roomList = roomsResponse.rooms ?? [];
setRooms(roomList);
setActiveRoomId((current) => current ?? roomList[0]?.id ?? null);
}, [groupId, isPinLocked, token]);
const loadMessages = useCallback(async (roomId: string) => {
if (!token || isPinLocked) return;
const response = await fetchChatMessages(roomId, token);
setMessages(response.messages ?? []);
}, [isPinLocked, token]);
useEffect(() => {
if (!isReady || !token || isPinLocked) return;
setLoading(true);
loadGroup()
.catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка'))
.finally(() => setLoading(false));
}, [isPinLocked, isReady, loadGroup, showToast, token]);
useEffect(() => {
if (!activeRoomId) return;
void loadMessages(activeRoomId);
}, [activeRoomId, loadMessages]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
return subscribe((event) => {
if (event.type !== 'chat_message') return;
const payload = event.payload;
if (!payload?.roomId || payload.roomId !== activeRoomId) return;
if (payload.message) {
appendMessage(payload.message);
} else {
void loadMessages(activeRoomId!);
}
void loadGroup();
});
}, [activeRoomId, appendMessage, loadGroup, loadMessages, subscribe]);
const loadChatMedia = useCallback(
async (message: ChatMessage, force = false) => {
if (!token || !activeRoomId || !message.storageKey) return;
const storageKey = message.storageKey;
const existing = mediaUrlsRef.current[storageKey];
if (!force && existing && !shouldRefreshMedia(existing.expiresAt)) return;
const meta = parseMessageMetadata(message.metadataJson);
const fileName = meta.fileName || message.content || undefined;
const query = new URLSearchParams({ storageKey });
if (fileName) query.set('fileName', fileName);
try {
const access = await apiFetch<{ accessUrl: string; expiresAt: string }>(
`/media/chat/${activeRoomId}/media/url?${query.toString()}`,
{},
token
);
const blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token);
const blobUrl = createBlobObjectUrl(blob);
setMediaUrls((current) => {
revokeBlobObjectUrl(current[storageKey]?.blobUrl);
return {
...current,
[storageKey]: {
blobUrl,
expiresAt: mediaExpiresAtMs(access.expiresAt),
fileName
}
};
});
} catch {
// ignore failed media loads in background
}
},
[activeRoomId, token]
);
useEffect(() => {
if (!token || !activeRoomId) return;
messages.forEach((message) => {
if (message.storageKey) void loadChatMedia(message);
});
}, [activeRoomId, loadChatMedia, messages, token]);
useEffect(() => {
if (!token || !activeRoomId) return;
const timer = window.setInterval(() => {
messages.forEach((message) => {
if (!message.storageKey) return;
const cached = mediaUrlsRef.current[message.storageKey];
if (cached && shouldRefreshMedia(cached.expiresAt)) {
void loadChatMedia(message, true);
}
});
}, 60_000);
return () => window.clearInterval(timer);
}, [activeRoomId, loadChatMedia, messages, token]);
useEffect(() => {
return () => {
Object.values(mediaUrlsRef.current).forEach((entry) => revokeBlobObjectUrl(entry.blobUrl));
};
}, []);
async function downloadChatFile(message: ChatMessage) {
if (!token || !activeRoomId || !message.storageKey) return;
const storageKey = message.storageKey;
const meta = parseMessageMetadata(message.metadataJson);
const fileName = meta.fileName || message.content || 'file';
setDownloadingKey(storageKey);
try {
let blob: Blob | null = null;
const cached = mediaUrls[storageKey];
if (cached?.blobUrl) {
blob = await fetch(cached.blobUrl).then((response) => response.blob());
} else {
const query = new URLSearchParams({ storageKey });
if (fileName) query.set('fileName', fileName);
const access = await apiFetch<{ accessUrl: string }>(
`/media/chat/${activeRoomId}/media/url?${query.toString()}`,
{},
token
);
blob = await fetchAuthenticatedMediaBlob(`${access.accessUrl}?download=1`, token);
}
if (!blob) {
throw new Error('Не удалось получить файл');
}
triggerBlobDownload(blob, fileName);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось скачать файл') ?? 'Ошибка');
} finally {
setDownloadingKey(null);
}
}
async function saveRename() {
if (!token || !renameValue.trim()) return;
try {
const updated = await updateFamilyGroup(groupId, renameValue.trim(), token);
setGroup(updated);
showToast('Название семьи обновлено');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось переименовать семью') ?? 'Ошибка');
}
}
async function uploadFamilyAvatar(file: File) {
if (!token) return;
try {
const presigned = await apiFetch<PresignedUploadResponse>(
`/media/families/${groupId}/avatar/upload-url`,
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
token
);
await fetch(presigned.uploadUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file });
await apiFetch(`/media/families/${groupId}/avatar/confirm`, {
method: 'POST',
body: JSON.stringify({ storageKey: presigned.storageKey })
}, token);
await loadGroup();
showToast('Аватар семьи обновлён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить аватар') ?? 'Ошибка');
}
}
async function submitInvite() {
if (!token || !inviteTarget.trim()) return;
try {
await sendFamilyInvite(groupId, inviteTarget.trim(), token);
setInviteOpen(false);
setInviteTarget('');
showToast('Приглашение отправлено');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка');
}
}
async function submitCreateRoom() {
if (!token || !newRoomName.trim()) return;
try {
const room = await createChatRoom(groupId, newRoomName.trim(), selectedMembers, token);
setRooms((current) => [...current, room]);
setActiveRoomId(room.id);
setCreateRoomOpen(false);
setNewRoomName('');
setSelectedMembers([]);
showToast('Групповой чат создан');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось создать чат') ?? 'Ошибка');
}
}
async function sendText(type: string = 'TEXT') {
if (!token || !activeRoomId || sending) return;
const content = draft.trim();
if (!content && type === 'TEXT') return;
setSending(true);
try {
const message = await sendChatMessage(activeRoomId, { type, content }, token);
appendMessage(message);
setDraft('');
setEmojiOpen(false);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) {
if (!token || !activeRoomId) return;
setSending(true);
try {
const contentType = resolveChatContentType(file);
const messageType = detectChatMessageType(file, { voice: options?.voice });
const metadata = {
fileName: file.name,
fileSize: file.size,
...(options?.durationMs ? { durationMs: options.durationMs } : {})
};
const presigned = await apiFetch<PresignedUploadResponse>(
`/media/chat/${activeRoomId}/media/upload-url`,
{
method: 'POST',
body: JSON.stringify({ contentType, fileName: file.name })
},
token
);
await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: file
});
const message = await sendChatMessage(
activeRoomId,
{
type: messageType,
storageKey: presigned.storageKey,
mimeType: contentType,
content:
messageType === 'VOICE'
? 'Голосовое сообщение'
: messageType === 'FILE'
? file.name
: undefined,
metadataJson: JSON.stringify(metadata)
},
token
);
appendMessage(message);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function submitPoll() {
if (!token || !activeRoomId) return;
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
if (!pollQuestion.trim() || options.length < 2) {
showToast('Укажите вопрос и минимум 2 варианта');
return;
}
setSending(true);
try {
const message = await sendChatMessage(
activeRoomId,
{ type: 'POLL', poll: { question: pollQuestion.trim(), options } },
token
);
appendMessage(message);
setPollOpen(false);
setPollQuestion('');
setPollOptions(['', '']);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось создать опрос') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function toggleMute() {
if (!token || !activeRoomId || !activeRoom) return;
const muted = !myMembership?.notificationsMuted;
await setChatRoomMuted(activeRoomId, muted, token);
setRooms((current) =>
current.map((room) =>
room.id === activeRoomId
? {
...room,
members: room.members.map((member) =>
member.userId === user?.id ? { ...member, notificationsMuted: muted } : member
)
}
: room
)
);
showToast(muted ? 'Уведомления чата выключены' : 'Уведомления чата включены');
}
async function startVoiceRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg'];
const mimeType = preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)) ?? '';
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunks.push(event.data);
};
recorder.onstop = () => {
stream.getTracks().forEach((track) => track.stop());
const durationMs = recordingStartedAtRef.current ? Date.now() - recordingStartedAtRef.current : undefined;
recordingStartedAtRef.current = null;
const blob = new Blob(chunks, { type: 'audio/webm' });
void uploadChatFile(new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' }), {
voice: true,
durationMs
});
};
mediaRecorderRef.current = recorder;
recordingStartedAtRef.current = Date.now();
recorder.start();
setRecording(true);
} catch {
showToast('Не удалось получить доступ к микрофону');
}
}
function stopVoiceRecording() {
mediaRecorderRef.current?.stop();
mediaRecorderRef.current = null;
setRecording(false);
}
if (!isReady || loading) {
return <div className="py-20 text-center text-[#667085]">Загрузка семьи...</div>;
}
return (
<div className="flex h-[calc(100vh-120px)] min-h-[640px] overflow-hidden rounded-[28px] border border-[#eceef4] bg-[#eef2f8]">
<aside className="flex w-[300px] shrink-0 flex-col border-r border-[#e4e8ef] bg-white">
<div className="border-b border-[#eceef4] p-4">
<button type="button" className="mb-3 flex items-center gap-2 text-sm text-[#667085]" onClick={() => router.push('/family')}>
<ArrowLeft className="h-4 w-4" />
Все семьи
</button>
<div className="flex items-center gap-3">
<button type="button" onClick={() => avatarInputRef.current?.click()}>
<FamilyAvatar groupId={groupId} name={group?.name ?? 'С'} hasAvatar={group?.hasAvatar} token={token} />
</button>
<div className="min-w-0 flex-1">
<Input value={renameValue} onChange={(event) => setRenameValue(event.target.value)} className="h-9" />
<Button size="sm" variant="secondary" className="mt-2 h-8 rounded-xl" onClick={() => void saveRename()}>
Сохранить название
</Button>
</div>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" variant="secondary" className="flex-1 rounded-xl" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-1 h-4 w-4" />
Пригласить
</Button>
<Button size="sm" variant="secondary" className="rounded-xl" onClick={() => avatarInputRef.current?.click()}>
<Camera className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<p className="text-sm font-semibold text-[#667085]">Чаты</p>
<Button size="sm" variant="ghost" className="h-8 w-8 rounded-xl p-0" onClick={() => setCreateRoomOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{rooms.map((room) => (
<button
key={room.id}
type="button"
onClick={() => setActiveRoomId(room.id)}
className={cn(
'flex w-full items-start gap-3 px-4 py-3 text-left transition hover:bg-[#f7f9fc]',
activeRoomId === room.id && 'bg-[#3390ec]/10'
)}
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#3390ec] text-sm font-semibold text-white">
{room.name.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-medium">{room.name}</p>
{room.lastMessage ? (
<span className="shrink-0 text-[11px] text-[#a8adbc]">{formatMessageTime(room.lastMessage.createdAt)}</span>
) : null}
</div>
<p className="truncate text-sm text-[#667085]">
{room.lastMessage?.content ?? (room.type === 'GENERAL' ? 'Общий чат семьи' : 'Нет сообщений')}
</p>
</div>
</button>
))}
</div>
</aside>
<section className="flex min-w-0 flex-1 flex-col bg-[#e6ebf2]">
{activeRoom ? (
<>
<div className="flex items-center justify-between border-b border-[#dce3ec] bg-white px-4 py-3">
<div>
<h2 className="font-semibold">{activeRoom.name}</h2>
<p className="text-xs text-[#667085]">{activeRoom.members.length} участников</p>
</div>
<Button size="sm" variant="secondary" className="rounded-xl" onClick={() => void toggleMute()}>
{myMembership?.notificationsMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</Button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-4">
{messages.map((message) => {
const mine = message.senderId === user?.id;
return (
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm',
mine ? 'rounded-br-md bg-[#effdde] text-[#1f2430]' : 'rounded-bl-md bg-white text-[#1f2430]'
)}
>
{!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null}
{message.type === 'POLL' && message.poll ? (
<div className="space-y-2">
<p className="font-medium">{message.poll.question}</p>
{message.poll.options.map((option) => {
const total = message.poll!.options.reduce((sum, item) => sum + item.voteCount, 0) || 1;
const percent = Math.round((option.voteCount / total) * 100);
return (
<button
key={option.id}
type="button"
className="relative w-full overflow-hidden rounded-xl border border-[#dce3ec] px-3 py-2 text-left text-sm"
onClick={() => void voteChatPoll(message.id, [option.id], token).then((updated) => {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
})}
>
<div className="absolute inset-y-0 left-0 bg-[#3390ec]/15" style={{ width: `${percent}%` }} />
<span className="relative flex justify-between gap-2">
<span>{option.text}</span>
<span>{percent}%</span>
</span>
</button>
);
})}
</div>
) : message.storageKey ? (
(() => {
const media = mediaUrls[message.storageKey];
const meta = parseMessageMetadata(message.metadataJson);
const fileName = meta.fileName || message.content || 'Файл';
if (!media?.blobUrl) {
return (
<div className="flex items-center gap-2 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка файла...
</div>
);
}
if (message.type === 'IMAGE') {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={media.blobUrl} alt={fileName} className="max-h-72 max-w-full rounded-xl object-cover" />
);
}
if (message.type === 'VOICE' || message.type === 'AUDIO') {
return (
<div className="flex min-w-[220px] items-center gap-3 rounded-xl bg-black/5 px-3 py-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#3390ec] text-white">
{message.type === 'VOICE' ? <Mic className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-[#667085]">
{message.type === 'VOICE' ? 'Голосовое сообщение' : 'Аудиофайл'}
</p>
<audio controls preload="metadata" src={media.blobUrl} className="mt-1 h-8 w-full max-w-[240px]" />
</div>
</div>
);
}
return (
<button
type="button"
disabled={downloadingKey === message.storageKey}
onClick={() => void downloadChatFile(message)}
className="flex min-w-[220px] items-center gap-3 rounded-xl bg-black/5 px-3 py-2 text-left transition hover:bg-black/10 disabled:opacity-70"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#3390ec] text-xs font-bold text-white">
{downloadingKey === message.storageKey ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
fileIconLabel(fileName, message.mimeType)
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{fileName}</p>
<p className="text-xs text-[#667085]">{formatFileSize(meta.fileSize) || 'Документ'}</p>
</div>
<FileText className="h-4 w-4 shrink-0 text-[#667085]" />
</button>
);
})()
) : (
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed">{message.content}</p>
)}
<p className={cn('mt-1 text-[11px]', mine ? 'text-[#7ea06a]' : 'text-[#a8adbc]')}>
{formatMessageTime(message.createdAt)}
</p>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-[#dce3ec] bg-white px-3 py-3">
{emojiOpen ? (
<div className="mb-2 flex flex-wrap gap-1 rounded-2xl bg-[#f4f5f8] p-2">
{EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
className="rounded-lg px-2 py-1 text-xl hover:bg-white"
onClick={() => setDraft((current) => `${current}${emoji}`)}
>
{emoji}
</button>
))}
</div>
) : null}
<div className="flex items-end gap-2">
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => fileInputRef.current?.click()}>
<Paperclip className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => setPollOpen(true)}>
<BarChart3 className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => setEmojiOpen((value) => !value)}>
<Smile className="h-4 w-4" />
</Button>
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Сообщение"
rows={1}
className="max-h-32 min-h-[44px] flex-1 resize-none rounded-[20px] border border-[#dce3ec] bg-[#f4f5f8] px-4 py-3 text-[15px] outline-none focus:border-[#3390ec]"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void sendText();
}
}}
/>
{draft.trim() ? (
<Button className="h-11 w-11 rounded-full p-0" disabled={sending} onClick={() => void sendText()}>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
) : (
<Button
className={cn('h-11 w-11 rounded-full p-0', recording && 'bg-red-500 hover:bg-red-600')}
onMouseDown={() => void startVoiceRecording()}
onMouseUp={stopVoiceRecording}
onMouseLeave={stopVoiceRecording}
onTouchStart={() => void startVoiceRecording()}
onTouchEnd={stopVoiceRecording}
>
<Mic className="h-4 w-4" />
</Button>
)}
</div>
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center text-[#667085]">Выберите чат</div>
)}
</section>
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadFamilyAvatar(file);
event.target.value = '';
}} />
<input ref={fileInputRef} type="file" className="hidden" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadChatFile(file);
event.target.value = '';
}} />
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
<DialogHeader><DialogTitle>Пригласить в семью</DialogTitle></DialogHeader>
<Input placeholder="Email, телефон или логин" value={inviteTarget} onChange={(event) => setInviteTarget(event.target.value)} />
<Button className="mt-4 w-full rounded-xl" onClick={() => void submitInvite()}>Отправить приглашение</Button>
</DialogContent>
</Dialog>
<Dialog open={createRoomOpen} onOpenChange={setCreateRoomOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader><DialogTitle>Новый групповой чат</DialogTitle></DialogHeader>
<Input placeholder="Название чата" value={newRoomName} onChange={(event) => setNewRoomName(event.target.value)} />
<div className="mt-3 space-y-2">
<p className="text-sm font-medium">Участники</p>
{(group?.members ?? []).filter((member) => member.userId !== user?.id).map((member) => (
<label key={member.id} className="flex items-center gap-2 rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm">
<input
type="checkbox"
checked={selectedMembers.includes(member.userId)}
onChange={(event) => {
setSelectedMembers((current) =>
event.target.checked ? [...current, member.userId] : current.filter((id) => id !== member.userId)
);
}}
/>
{member.displayName}
</label>
))}
</div>
<Button className="mt-4 w-full rounded-xl" onClick={() => void submitCreateRoom()}>Создать чат</Button>
</DialogContent>
</Dialog>
<Dialog open={pollOpen} onOpenChange={setPollOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader><DialogTitle>Создать опрос</DialogTitle></DialogHeader>
<Input placeholder="Вопрос" value={pollQuestion} onChange={(event) => setPollQuestion(event.target.value)} />
<div className="mt-3 space-y-2">
{pollOptions.map((option, index) => (
<Input
key={index}
placeholder={`Вариант ${index + 1}`}
value={option}
onChange={(event) => setPollOptions((current) => current.map((item, i) => (i === index ? event.target.value : item)))}
/>
))}
</div>
<Button variant="secondary" className="mt-2 rounded-xl" onClick={() => setPollOptions((current) => [...current, ''])}>
Добавить вариант
</Button>
<Button className="mt-4 w-full rounded-xl" onClick={() => void submitPoll()}>Отправить опрос</Button>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
export function ActionTile({
icon: Icon,
label,
description,
className,
onClick
}: {
icon: LucideIcon;
label: string;
description?: string;
className?: string;
onClick?: () => void;
}) {
const content = (
<>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white">
<Icon className="h-5 w-5" />
</div>
<div className="max-w-[92px] text-[12px] font-medium leading-tight">{label}</div>
{description ? <p className="line-clamp-2 text-[11px] leading-tight text-[#667085]">{description}</p> : null}
</>
);
const baseClass = cn('flex min-h-[92px] flex-col items-center justify-center gap-2 rounded-[22px] bg-[#f4f5f8] p-3 text-center', className);
if (onClick) {
return (
<button type="button" onClick={onClick} className={cn(baseClass, 'transition hover:bg-[#eceef4]')}>
{content}
</button>
);
}
return <div className={baseClass}>{content}</div>;
}
export function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-4 mt-8 text-[26px] font-medium tracking-tight">{children} </h2>;
}

View File

@@ -0,0 +1,24 @@
'use client';
import { ShieldCheck, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PublicUser } from '@/lib/api';
export function AdminBadge({ user, className }: { user: PublicUser; className?: string }) {
if (!user.isSuperAdmin && !user.canAccessAdmin) return null;
const label = user.isSuperAdmin ? 'Супер-админ' : user.roles?.includes('admin') ? 'Администратор' : 'Админ';
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold',
user.isSuperAdmin ? 'bg-gradient-to-r from-[#20212b] to-[#3d4255] text-white shadow-sm' : 'bg-[#eef4ff] text-[#1d4ed8]',
className
)}
>
{user.isSuperAdmin ? <Sparkles className="h-3.5 w-3.5" /> : <ShieldCheck className="h-3.5 w-3.5" />}
{label}
</span>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { Boxes, Settings2, ShieldCheck, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PublicUser } from '@/lib/api';
const links = [
{ href: '/admin/users', label: 'Пользователи', icon: Users, permission: 'canViewUsers' as const, altPermission: 'canManageUsers' as const },
{ href: '/admin/oauth', label: 'OAuth', icon: Boxes, permission: 'canManageOAuth' as const },
{ href: '/admin/rbac', label: 'Роли', icon: ShieldCheck, permission: 'canManageRoles' as const, superAdminOnly: true },
{ href: '/admin/settings', label: 'Настройки', icon: Settings2, permission: 'canManageSettings' as const }
];
export function AdminNav({ active, user }: { active: string; user: PublicUser }) {
const visible = links.filter((link) => {
if (link.superAdminOnly && !user.canManageRoles) return false;
return Boolean(user[link.permission]) || (link.altPermission ? Boolean(user[link.altPermission]) : false) || user.isSuperAdmin;
});
return (
<nav className="mb-8 flex flex-wrap gap-2">
{visible.map((link) => {
const isActive = active === link.href;
return (
<Link
key={link.href}
href={link.href}
className={cn(
'inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors',
isActive ? 'bg-black !text-white shadow-sm' : 'bg-[#f4f5f8] text-[#1f2430] hover:bg-[#eceef4]'
)}
>
<link.icon className={cn('h-4 w-4', isActive ? '!text-white' : 'text-current')} />
<span className={isActive ? '!text-white' : undefined}>{link.label}</span>
</Link>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/id/auth-provider';
import { AdminBadge } from '@/components/id/admin-badge';
import { AdminNav } from '@/components/id/admin-nav';
import { IdShell } from '@/components/id/shell';
export function AdminShell({ active, children }: { active: string; children: React.ReactNode }) {
const router = useRouter();
const { user, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && user && !user.canAccessAdmin) {
router.replace('/');
}
}, [isLoading, router, user]);
if (isLoading || !user) {
return (
<IdShell active={active} wide>
<div className="py-20 text-center text-[#667085]">Загрузка админ-панели...</div>
</IdShell>
);
}
if (!user.canAccessAdmin) {
return null;
}
return (
<IdShell active="/admin/users" wide>
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2">
<AdminBadge user={user} />
</div>
<h1 className="text-4xl font-medium tracking-tight">Админ-панель</h1>
<p className="mt-2 text-[#667085]">Управление пользователями, OAuth-приложениями и правами доступа</p>
</div>
</div>
<AdminNav active={active} user={user} />
{children}
</IdShell>
);
}

View File

@@ -0,0 +1,473 @@
'use client';
import * as React from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
apiFetch,
ApiError,
AUTH_REFRESH_KEY,
AUTH_SESSION_KEY,
AUTH_TOKEN_KEY,
AUTH_USER_CACHE_KEY,
AuthSessionResponse,
AuthTokens,
fetchAuthSession,
getDeviceFingerprint,
IdentifyResponse,
getApiErrorMessage,
isPinRequiredError,
OtpSendResponse,
PasswordlessAuthResponse,
PinVerificationResponse,
PublicUser,
refreshAuthSession,
resetPinRequiredNotification,
setPinRequiredHandler
} from '@/lib/api';
import { useToast } from './toast-provider';
import { PinLockModal } from './pin-lock-modal';
interface AuthContextValue {
user: PublicUser | null;
token: string | null;
isLoading: boolean;
isPinLocked: boolean;
hasStoredSession: boolean;
login: (login: string, password: string) => Promise<AuthTokens>;
identifyLogin: (login: string) => Promise<IdentifyResponse>;
sendLoginOtp: (recipient: string, channel?: string) => Promise<string>;
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
loginWithLdap: (username: string, password: string) => Promise<AuthTokens>;
completePin: (sessionId: string, pin: string) => Promise<void>;
register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
refreshProfile: () => Promise<void>;
logout: () => void;
}
const AuthContext = React.createContext<AuthContextValue | null>(null);
function cacheUserProfile(user: PublicUser) {
window.localStorage.setItem(AUTH_USER_CACHE_KEY, JSON.stringify(user));
}
function readCachedUserProfile(): PublicUser | null {
const raw = window.localStorage.getItem(AUTH_USER_CACHE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as PublicUser;
} catch {
return null;
}
}
function persistPartialAuth(auth: AuthTokens) {
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
cacheUserProfile(auth.user);
}
function applySessionState(
session: AuthSessionResponse,
setters: {
setUser: React.Dispatch<React.SetStateAction<PublicUser | null>>;
setToken: React.Dispatch<React.SetStateAction<string | null>>;
activatePinLock: (sessionId: string) => void;
clearPinLock: () => void;
}
) {
setters.setUser(session.user);
cacheUserProfile(session.user);
if (session.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, session.sessionId);
}
if (session.requiresPin) {
setters.activatePinLock(session.sessionId ?? '');
} else {
setters.clearPinLock();
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { showToast } = useToast();
const [token, setToken] = React.useState<string | null>(null);
const [user, setUser] = React.useState<PublicUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [isPinLocked, setIsPinLocked] = React.useState(false);
const [hasStoredSession, setHasStoredSession] = React.useState(false);
const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null);
const [pinSubmitting, setPinSubmitting] = React.useState(false);
const [pinError, setPinError] = React.useState<string | null>(null);
const isPinLockedRef = React.useRef(false);
const refreshInFlightRef = React.useRef<Promise<void> | null>(null);
React.useEffect(() => {
isPinLockedRef.current = isPinLocked;
}, [isPinLocked]);
const clearPinLock = React.useCallback(() => {
setIsPinLocked(false);
setLockedSessionId(null);
setPinError(null);
resetPinRequiredNotification();
}, []);
const activatePinLock = React.useCallback((sessionId: string) => {
const resolvedSessionId = sessionId || window.localStorage.getItem(AUTH_SESSION_KEY);
if (!resolvedSessionId) return;
setLockedSessionId(resolvedSessionId);
setIsPinLocked(true);
setPinError(null);
setHasStoredSession(true);
const cachedUser = readCachedUserProfile();
if (cachedUser) {
setUser(cachedUser);
}
}, []);
const saveSession = React.useCallback(
(auth: AuthTokens) => {
window.localStorage.setItem(AUTH_TOKEN_KEY, auth.accessToken);
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
setToken(auth.accessToken);
setUser(auth.user);
cacheUserProfile(auth.user);
setHasStoredSession(true);
clearPinLock();
},
[clearPinLock]
);
const logout = React.useCallback(() => {
window.localStorage.removeItem(AUTH_TOKEN_KEY);
window.localStorage.removeItem(AUTH_REFRESH_KEY);
window.localStorage.removeItem(AUTH_SESSION_KEY);
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
setToken(null);
setUser(null);
setHasStoredSession(false);
clearPinLock();
router.push('/auth/login');
}, [clearPinLock, router]);
const refreshProfile = React.useCallback(async () => {
if (refreshInFlightRef.current) {
return refreshInFlightRef.current;
}
const task = (async () => {
const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
setHasStoredSession(Boolean(refreshToken));
if (!currentToken && !refreshToken) {
setIsLoading(false);
return;
}
try {
if (currentToken) {
setToken(currentToken);
try {
const session = await fetchAuthSession(currentToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
return;
} catch (error) {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return;
}
if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') {
throw error;
}
}
}
if (refreshToken) {
const refreshed = await refreshAuthSession();
if (refreshed.accessToken) {
window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken);
setToken(refreshed.accessToken);
}
if (refreshed.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId);
}
if (refreshed.requiresPin) {
if (refreshed.user) {
setUser(refreshed.user);
cacheUserProfile(refreshed.user);
}
activatePinLock(refreshed.sessionId ?? '');
return;
}
if (refreshed.accessToken) {
const session = await fetchAuthSession(refreshed.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
return;
}
}
throw new ApiError('Сессия недействительна', 401);
} catch (error) {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return;
}
if (refreshToken && error instanceof ApiError && error.status === 403) {
activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return;
}
if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) {
logout();
return;
}
const message = getApiErrorMessage(error, 'Не удалось загрузить профиль');
if (message) showToast(message);
logout();
} finally {
setIsLoading(false);
refreshInFlightRef.current = null;
}
})();
refreshInFlightRef.current = task;
return task;
}, [activatePinLock, clearPinLock, logout, showToast]);
React.useEffect(() => {
setPinRequiredHandler(activatePinLock);
return () => setPinRequiredHandler(null);
}, [activatePinLock]);
React.useEffect(() => {
void refreshProfile();
}, [refreshProfile]);
React.useEffect(() => {
function handleVisibilityChange() {
if (document.visibilityState !== 'visible') return;
if (isPinLockedRef.current) return;
if (!window.localStorage.getItem(AUTH_REFRESH_KEY)) return;
void refreshProfile();
}
function handleAuthRefreshed(event: Event) {
const detail = (event as CustomEvent<{ accessToken?: string; user?: PublicUser }>).detail;
if (detail.accessToken) {
setToken(detail.accessToken);
}
if (detail.user) {
setUser(detail.user);
cacheUserProfile(detail.user);
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('lendry:auth-refreshed', handleAuthRefreshed);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('lendry:auth-refreshed', handleAuthRefreshed);
};
}, [refreshProfile]);
const login = React.useCallback(
async (loginValue: string, password: string) => {
const auth = await apiFetch<AuthTokens>('/auth/login', {
method: 'POST',
body: JSON.stringify({
login: loginValue,
password,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const identifyLogin = React.useCallback(async (loginValue: string) => {
return apiFetch<IdentifyResponse>('/auth/identify', {
method: 'POST',
body: JSON.stringify({ login: loginValue })
});
}, []);
const sendLoginOtp = React.useCallback(async (recipient: string, channel?: string) => {
const response = await apiFetch<OtpSendResponse>('/auth/otp/send', {
method: 'POST',
body: JSON.stringify({ recipient, channel })
});
return response.maskedTarget;
}, []);
const verifyLoginOtp = React.useCallback(
async (recipient: string, code: string) => {
const response = await apiFetch<PasswordlessAuthResponse>('/auth/otp/verify', {
method: 'POST',
body: JSON.stringify({
recipient,
code,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (response.auth?.pinVerified) {
saveSession(response.auth);
} else if (response.auth) {
persistPartialAuth(response.auth);
setHasStoredSession(true);
}
return response;
},
[saveSession]
);
const loginWithPassword = React.useCallback(
async (loginValue: string, passwordValue: string) => {
const auth = await apiFetch<AuthTokens>('/auth/login/password', {
method: 'POST',
body: JSON.stringify({
login: loginValue,
password: passwordValue,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const loginWithLdap = React.useCallback(
async (username: string, passwordValue: string) => {
const auth = await apiFetch<AuthTokens>('/auth/ldap/login', {
method: 'POST',
body: JSON.stringify({
username,
password: passwordValue,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const completePin = React.useCallback(
async (sessionId: string, pin: string) => {
const response = await apiFetch<PinVerificationResponse>('/auth/pin/verify', {
method: 'POST',
body: JSON.stringify({ sessionId, pin })
});
window.localStorage.setItem(AUTH_TOKEN_KEY, response.accessToken);
window.localStorage.setItem(AUTH_SESSION_KEY, sessionId);
setToken(response.accessToken);
const session = await fetchAuthSession(response.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
if (pathname.startsWith('/auth/')) {
router.replace('/');
}
},
[activatePinLock, clearPinLock, pathname, router]
);
const handlePinUnlock = React.useCallback(
async (pin: string) => {
const sessionId = lockedSessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY);
if (!sessionId) {
setPinError('Сессия не найдена. Войдите снова.');
return;
}
setPinSubmitting(true);
setPinError(null);
try {
await completePin(sessionId, pin);
} catch (error) {
setPinError(error instanceof Error ? error.message : 'Не удалось подтвердить PIN-код');
} finally {
setPinSubmitting(false);
}
},
[completePin, lockedSessionId]
);
const register = React.useCallback(
async (data: { displayName: string; login: string; password: string }) => {
const isEmail = data.login.includes('@');
await apiFetch<PublicUser>('/auth/register', {
method: 'POST',
body: JSON.stringify({
displayName: data.displayName,
password: data.password,
...(isEmail ? { email: data.login } : { phone: data.login })
})
});
await login(data.login, data.password);
},
[login]
);
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isPinLocked,
hasStoredSession,
login,
identifyLogin,
sendLoginOtp,
verifyLoginOtp,
loginWithPassword,
loginWithLdap,
completePin,
register,
refreshProfile,
logout
}}
>
{children}
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
</AuthContext.Provider>
);
}
export function useAuth() {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth должен использоваться внутри AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,127 @@
'use client';
import { useRef, useState } from 'react';
import { Camera, Loader2 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/id/toast-provider';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { apiFetch, getApiErrorMessage } from '@/lib/api';
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt: string;
}
export function AvatarUpload({
userId,
displayName,
hasAvatar,
token,
onUpdated
}: {
userId: string;
displayName?: string;
hasAvatar?: boolean;
token: string | null;
onUpdated: () => Promise<void>;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const { showToast } = useToast();
const [isUploading, setIsUploading] = useState(false);
const { avatarUrl, refreshAvatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !token) return;
if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type)) {
showToast('Допустимы только JPEG, PNG, WEBP или GIF');
return;
}
setIsUploading(true);
try {
const presigned = await apiFetch<PresignedUploadResponse>(
'/media/avatars/upload-url',
{
method: 'POST',
body: JSON.stringify({ contentType: file.type })
},
token
);
const uploadResponse = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (!uploadResponse.ok) {
throw new Error('Не удалось загрузить файл в хранилище');
}
await apiFetch('/media/avatars/confirm', {
method: 'POST',
body: JSON.stringify({ storageKey: presigned.storageKey })
}, token);
await onUpdated();
await refreshAvatarUrl();
showToast('Аватар обновлён');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить аватар');
if (message) showToast(message);
} finally {
setIsUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
return (
<div className="relative inline-flex">
<Avatar className="h-24 w-24 border-4 border-white shadow-xl">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
</Avatar>
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleFileChange} />
<Button
type="button"
size="sm"
variant="secondary"
className="absolute -bottom-2 left-1/2 -translate-x-1/2 rounded-full px-3 shadow"
disabled={isUploading || !token}
onClick={() => inputRef.current?.click()}
>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
{isUploading ? 'Загрузка...' : 'Фото'}
</Button>
</div>
);
}
export function AvatarDisplay({
userId,
displayName,
hasAvatar,
token,
className
}: {
userId: string;
displayName?: string;
hasAvatar?: boolean;
token: string | null;
className?: string;
}) {
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
return (
<Avatar className={className}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
</Avatar>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { cn } from '@/lib/utils';
import { usePublicSettings } from '@/components/id/public-settings-provider';
function splitProjectName(projectName: string) {
const trimmed = projectName.trim();
const match = trimmed.match(/^(.+?)\s+ID$/i);
if (match) {
return { brand: match[1].trim(), suffix: 'ID' };
}
return { brand: trimmed || 'MVK', suffix: 'ID' };
}
export function BrandLogo({
className,
size = 'md',
variant = 'dark'
}: {
className?: string;
size?: 'sm' | 'md' | 'lg';
variant?: 'dark' | 'light';
}) {
const { projectName } = usePublicSettings();
const { brand, suffix } = splitProjectName(projectName);
const sizeClass = size === 'lg' ? 'text-2xl' : size === 'sm' ? 'text-lg' : 'text-xl';
const badgeClass =
variant === 'light'
? 'rounded-full border border-white px-1 text-base'
: 'rounded-full bg-black px-1 text-sm text-white';
return (
<div className={cn('font-bold tracking-tight', sizeClass, className)}>
{brand} <span className={badgeClass}>{suffix}</span>
</div>
);
}
export function ProjectTagline({ className }: { className?: string }) {
const { projectTagline } = usePublicSettings();
return <p className={className}>{projectTagline}</p>;
}

View File

@@ -0,0 +1,62 @@
'use client';
import * as React from 'react';
import { KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
interface PinLockModalProps {
open: boolean;
isSubmitting: boolean;
error?: string | null;
onSubmit: (pin: string) => Promise<void>;
}
export function PinLockModal({ open, isSubmitting, error, onSubmit }: PinLockModalProps) {
const [pin, setPin] = React.useState('');
React.useEffect(() => {
if (open) {
setPin('');
}
}, [open]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSubmit(pin);
}
return (
<Dialog open={open} onOpenChange={() => undefined}>
<DialogContent className="[&>button]:hidden" onPointerDownOutside={(event) => event.preventDefault()} onEscapeKeyDown={(event) => event.preventDefault()}>
<DialogHeader>
<div className="mx-auto mb-2 flex h-14 w-14 items-center justify-center rounded-full bg-[#f4f5f8]">
<KeyRound className="h-7 w-7 text-[#111]" />
</div>
<DialogTitle className="text-center">Введите PIN-код</DialogTitle>
<p className="text-center text-sm text-[#6b6f7b]">Сессия заблокирована из-за бездействия. Подтвердите PIN, чтобы продолжить работу.</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
autoFocus
className="h-[58px] text-center text-lg tracking-[0.4em]"
placeholder="PIN"
value={pin}
onChange={(event) => setPin(event.target.value)}
required
minLength={4}
maxLength={6}
inputMode="numeric"
autoComplete="one-time-code"
/>
{error ? <p className="text-center text-sm text-red-600">{error}</p> : null}
<Button type="submit" size="lg" className="w-full rounded-[18px]" disabled={isSubmitting || pin.length < 4}>
{isSubmitting ? 'Проверяем...' : 'Разблокировать'}
</Button>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import { useEffect } from 'react';
import { usePublicSettings } from '@/components/id/public-settings-provider';
export function ProjectHead() {
const { projectName, projectTagline } = usePublicSettings();
useEffect(() => {
document.title = projectName;
const meta = document.querySelector('meta[name="description"]');
if (meta) {
meta.setAttribute('content', projectTagline);
}
}, [projectName, projectTagline]);
return null;
}

View File

@@ -0,0 +1,64 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { apiFetch } from '@/lib/api';
interface PublicSettingsContextValue {
projectName: string;
projectTagline: string;
ldapEnabled: boolean;
ldapUseLdaps: boolean;
isLoading: boolean;
refreshPublicSettings: () => Promise<void>;
}
const PublicSettingsContext = createContext<PublicSettingsContextValue>({
projectName: 'MVK ID',
projectTagline: 'Единый аккаунт для сервисов Lendry',
ldapEnabled: false,
ldapUseLdaps: false,
isLoading: true,
refreshPublicSettings: async () => undefined
});
export function PublicSettingsProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(true);
const refreshPublicSettings = useCallback(async () => {
try {
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
const map = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
setSettings(map);
} catch {
// Оставляем последние известные значения.
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void refreshPublicSettings();
const onFocus = () => void refreshPublicSettings();
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [refreshPublicSettings]);
const value = useMemo(
() => ({
projectName: settings.PROJECT_NAME || 'MVK ID',
projectTagline: settings.PROJECT_TAGLINE || 'Единый аккаунт для сервисов Lendry',
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
isLoading,
refreshPublicSettings
}),
[isLoading, refreshPublicSettings, settings.LDAP_ENABLED, settings.LDAP_USE_LDAPS, settings.PROJECT_NAME, settings.PROJECT_TAGLINE]
);
return <PublicSettingsContext.Provider value={value}>{children}</PublicSettingsContext.Provider>;
}
export function usePublicSettings() {
return useContext(PublicSettingsContext);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { Sidebar } from './sidebar';
import { UserMenu } from './user-menu';
import { NotificationBell } from '@/components/notifications/notification-bell';
export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) {
return (
<div className="min-h-screen bg-white">
<Sidebar active={active} />
<div className="fixed right-4 top-4 z-40 flex items-center gap-2 lg:right-8 lg:top-6">
<NotificationBell />
<UserMenu />
</div>
<main className="px-5 py-8 lg:pl-[170px] lg:pr-8">
<div className={wide ? 'mx-auto max-w-[920px]' : 'mx-auto max-w-[560px]'}>{children}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { FileText, Heart, Home, LockKeyhole, LogOut, ShieldCheck, UserRound } from 'lucide-react';
import { BrandLogo } from '@/components/id/brand-logo';
import { cn } from '@/lib/utils';
import { useAuth } from './auth-provider';
const baseItems = [
{ href: '/', label: 'Главная', icon: Home },
{ href: '/data', label: 'Данные', icon: UserRound },
{ href: '/documents', label: 'Документы', icon: FileText },
{ href: '/family', label: 'Семья', icon: Heart },
{ href: '/security', label: 'Безопасность', icon: LockKeyhole }
];
export function Sidebar({ active }: { active: string }) {
const { logout, user } = useAuth();
const items = user?.canAccessAdmin
? [...baseItems, { href: '/admin/users', label: 'Админка', icon: ShieldCheck }]
: baseItems;
return (
<aside className="fixed left-0 top-0 hidden h-screen w-[150px] flex-col justify-between border-r border-transparent bg-white px-3 py-7 text-[13px] lg:flex">
<div>
<Link href="/" className="mb-7 block">
<BrandLogo size="lg" variant="dark" />
</Link>
<nav className="space-y-1.5">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-xl px-3 py-2 text-[#1f2430] hover:bg-[#f4f5f8]',
(active === item.href || (item.href.startsWith('/admin') && active.startsWith('/admin'))) && 'bg-[#f4f5f8]'
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
</div>
<div className="space-y-2 text-[11px] text-[#667085]">
{user ? <p className="truncate font-medium text-[#1f2430]">{user.displayName}</p> : null}
<button type="button" onClick={logout} className="flex items-center gap-2 rounded-lg px-2 py-1 text-[#1f2430] hover:bg-[#f4f5f8]">
<LogOut className="h-3.5 w-3.5" />
Выйти
</button>
<p>Русский</p>
<p>Справка</p>
<p>© 2026 Lendry</p>
</div>
</aside>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import { Toast, ToastProvider as RadixToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast';
interface ToastState {
id: number;
title: string;
}
const ToastContext = React.createContext<{ showToast: (title: string) => void } | null>(null);
const PIN_TOAST_MESSAGE = 'Требуется подтверждение PIN-кода';
export function AppToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<ToastState[]>([]);
const recentMessagesRef = React.useRef<Map<string, number>>(new Map());
const showToast = React.useCallback((title: string) => {
if (title === PIN_TOAST_MESSAGE) return;
const now = Date.now();
const lastShown = recentMessagesRef.current.get(title);
if (lastShown && now - lastShown < 4000) return;
recentMessagesRef.current.set(title, now);
const id = now;
setToasts((current) => {
if (current.some((toast) => toast.title === title)) return current;
return [...current, { id, title }];
});
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, 4500);
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
<RadixToastProvider swipeDirection="right">
{children}
{toasts.map((toast) => (
<Toast key={toast.id} open>
<ToastTitle>{toast.title}</ToastTitle>
</Toast>
))}
<ToastViewport />
</RadixToastProvider>
</ToastContext.Provider>
);
}
export function useToast() {
const context = React.useContext(ToastContext);
if (!context) {
throw new Error('useToast должен использоваться внутри AppToastProvider');
}
return context;
}

View File

@@ -0,0 +1,125 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ChevronRight,
FileText,
Heart,
LockKeyhole,
LogOut,
ShieldCheck,
Smartphone,
UserRound
} from 'lucide-react';
import { AdminBadge } from '@/components/id/admin-badge';
import { useAuth } from '@/components/id/auth-provider';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
function maskPhone(phone?: string | null) {
if (!phone) return null;
const digits = phone.replace(/\D/g, '');
if (digits.length < 10) return phone;
return `+${digits.slice(0, 1)} ${digits.slice(1, 4)} ***-**-${digits.slice(-2)}`;
}
const menuItems = [
{ href: '/data', label: 'Личные данные', subtitle: 'ФИО, день рождения, пол', icon: UserRound },
{ href: '/security', label: 'Телефон и безопасность', subtitle: 'PIN, пароль, устройства', icon: Smartphone },
{ href: '/documents', label: 'Документы', subtitle: 'Паспорт, права, загран', icon: FileText },
{ href: '/family', label: 'Семья', subtitle: 'Участники и доступ', icon: Heart },
{ href: '/security', label: 'Защита аккаунта', subtitle: 'Способы входа и сессии', icon: LockKeyhole }
];
export function UserMenu({ className }: { className?: string }) {
const router = useRouter();
const { user, token, logout } = useAuth();
const { avatarUrl } = useAvatarUrl(user?.id, user?.hasAvatar, token);
if (!user) return null;
const contactLine = [maskPhone(user.phone), user.username ?? user.email].filter(Boolean).join(' · ');
const initials = user.displayName
.split(' ')
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn('flex items-center gap-2 rounded-full border border-[#eceef4] bg-white py-1 pl-1 pr-3 shadow-sm transition hover:bg-[#fafbfd]', className)}
aria-label="Меню пользователя"
>
<Avatar className="h-9 w-9">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={user.displayName} /> : null}
<AvatarFallback className="text-xs font-semibold">{initials}</AvatarFallback>
</Avatar>
<span className="hidden max-w-[120px] truncate text-sm font-medium md:inline">{user.displayName}</span>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(92vw,360px)] rounded-[28px] border-[#eceef4] p-0 shadow-2xl">
<div className="border-b border-[#eceef4] px-5 pb-5 pt-5 text-center">
<Avatar className="mx-auto h-20 w-20">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={user.displayName} /> : null}
<AvatarFallback className="text-lg font-semibold">{initials}</AvatarFallback>
</Avatar>
<div className="mt-3 flex items-center justify-center gap-2">
<p className="text-lg font-semibold">{user.displayName}</p>
<AdminBadge user={user} />
</div>
<p className="mt-1 text-sm text-[#667085]">{contactLine || 'Контакты не указаны'}</p>
</div>
<div className="p-2">
{menuItems.map((item) => (
<Link
key={`${item.href}-${item.label}`}
href={item.href}
className="flex items-center gap-3 rounded-[18px] px-3 py-3 transition hover:bg-[#f4f5f8]"
>
<item.icon className="h-5 w-5 shrink-0 text-[#667085]" />
<div className="min-w-0 flex-1 text-left">
<div className="font-medium">{item.label}</div>
<div className="truncate text-xs text-[#667085]">{item.subtitle}</div>
</div>
<ChevronRight className="h-4 w-4 text-[#a8adbc]" />
</Link>
))}
{user.canAccessAdmin ? (
<Link href="/admin/users" className="mt-1 flex items-center gap-3 rounded-[18px] px-3 py-3 transition hover:bg-[#f4f5f8]">
<ShieldCheck className="h-5 w-5 shrink-0 text-[#667085]" />
<div className="min-w-0 flex-1 text-left">
<div className="font-medium">Админ-панель</div>
<div className="text-xs text-[#667085]">Пользователи, OAuth и настройки</div>
</div>
<ChevronRight className="h-4 w-4 text-[#a8adbc]" />
</Link>
) : null}
<button
type="button"
onClick={() => {
logout();
router.push('/auth/login');
}}
className="mt-1 flex w-full items-center gap-3 rounded-[18px] px-3 py-3 text-left transition hover:bg-[#f4f5f8]"
>
<LogOut className="h-5 w-5 shrink-0 text-[#667085]" />
<div>
<div className="font-medium">Выйти</div>
<div className="text-xs text-[#667085]">Завершить текущую сессию</div>
</div>
</button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,233 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Bell, Loader2, Trash2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
AppNotification,
fetchNotifications,
fetchUnreadNotificationCount,
getApiErrorMessage,
markAllNotificationsRead,
markNotificationRead,
respondFamilyInvite
} from '@/lib/api';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { cn } from '@/lib/utils';
function formatTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'short' }).format(
new Date(value)
);
}
export function NotificationBell() {
const router = useRouter();
const { token, isPinLocked } = useAuth();
const { showToast } = useToast();
const { unreadCount, setUnreadCount, subscribe } = useRealtime();
const [open, setOpen] = useState(false);
const [items, setItems] = useState<AppNotification[]>([]);
const [loading, setLoading] = useState(false);
const [respondingId, setRespondingId] = useState<string | null>(null);
const load = useCallback(async () => {
if (!token || isPinLocked) return;
setLoading(true);
try {
const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]);
setItems(list.notifications ?? []);
setUnreadCount(count.count ?? 0);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить уведомления');
if (message) showToast(message);
} finally {
setLoading(false);
}
}, [isPinLocked, setUnreadCount, showToast, token]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
return subscribe((event) => {
void load();
if (event.type === 'chat_message' && event.title) {
showToast(`${event.title}: ${event.message}`);
}
if (event.type === 'family_invite') {
showToast(event.message || 'Новое приглашение в семью');
}
});
}, [load, showToast, subscribe]);
async function openItem(item: AppNotification) {
if (!token) return;
await markNotificationRead(item.id, token);
setUnreadCount((count) => Math.max(0, count - 1));
setItems((current) => current.filter((entry) => entry.id !== item.id));
let payload: Record<string, unknown> = {};
try {
payload = item.payloadJson ? JSON.parse(item.payloadJson) : {};
} catch {
payload = {};
}
if (payload.groupId) {
setOpen(false);
router.push(`/family/${payload.groupId}`);
}
}
function removeInviteNotification(inviteId: string) {
setItems((current) => {
let removedUnread = 0;
const next = current.filter((item) => {
if (item.type !== 'family_invite') return true;
try {
const payload = item.payloadJson ? JSON.parse(item.payloadJson) as { inviteId?: string } : {};
if (payload.inviteId === inviteId) {
if (!item.isRead) removedUnread += 1;
return false;
}
} catch {
// keep item if payload is malformed
}
return true;
});
if (removedUnread > 0) {
setUnreadCount((count) => Math.max(0, count - removedUnread));
}
return next;
});
}
async function acceptInvite(inviteId: string) {
if (!token) return;
setRespondingId(inviteId);
try {
const invite = await respondFamilyInvite(inviteId, true, token);
removeInviteNotification(inviteId);
showToast('Вы присоединились к семье');
setOpen(false);
router.push(`/family/${invite.groupId}`);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось принять приглашение');
if (message) showToast(message);
} finally {
setRespondingId(null);
}
}
async function declineInvite(inviteId: string) {
if (!token) return;
setRespondingId(inviteId);
try {
await respondFamilyInvite(inviteId, false, token);
removeInviteNotification(inviteId);
showToast('Приглашение отклонено');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось отклонить приглашение');
if (message) showToast(message);
} finally {
setRespondingId(null);
}
}
async function markAllRead() {
if (!token) return;
await markAllNotificationsRead(token);
setUnreadCount(0);
setItems([]);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="relative flex h-11 w-11 items-center justify-center rounded-2xl bg-[#f4f5f8] transition hover:bg-[#eceef4]"
aria-label="Уведомления"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-[#3390ec] px-1 text-[10px] font-semibold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[360px] rounded-[24px] p-0">
<div className="flex items-center justify-between border-b border-[#eceef4] px-4 py-3">
<h3 className="font-semibold">Уведомления</h3>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs" onClick={() => void markAllRead()}>
<Trash2 className="h-3.5 w-3.5" />
Очистить все
</Button>
</div>
<div className="max-h-[420px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-10 text-sm text-[#667085]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка...
</div>
) : items.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-[#667085]">Уведомлений пока нет</p>
) : (
items.map((item) => {
let inviteId: string | undefined;
try {
inviteId = item.payloadJson ? (JSON.parse(item.payloadJson).inviteId as string) : undefined;
} catch {
inviteId = undefined;
}
return (
<div
key={item.id}
className={cn(
'border-b border-[#eceef4] px-4 py-3 transition hover:bg-[#fafbfd]',
!item.isRead && 'bg-[#f0f8ff]/60'
)}
>
<button type="button" className="w-full text-left" onClick={() => void openItem(item)}>
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">{item.title}</p>
<p className="mt-1 text-sm text-[#667085]">{item.message}</p>
</div>
<span className="shrink-0 text-[11px] text-[#a8adbc]">{formatTime(item.createdAt)}</span>
</div>
</button>
{item.type === 'family_invite' && inviteId ? (
<div className="mt-3 flex gap-2">
<Button
size="sm"
className="h-8 flex-1 rounded-xl"
disabled={respondingId === inviteId}
onClick={() => void acceptInvite(inviteId!)}
>
{respondingId === inviteId ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Принять'}
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 flex-1 rounded-xl"
onClick={() => void declineInvite(inviteId!)}
>
Отклонить
</Button>
</div>
) : null}
</div>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import * as React from 'react';
import { useAuth } from '@/components/id/auth-provider';
import { WS_URL, type ChatMessage } from '@/lib/api';
export interface RealtimeEvent {
userId?: string;
type: string;
title: string;
message: string;
payload?: Record<string, unknown> & {
notificationId?: string;
roomId?: string;
message?: ChatMessage;
inviteId?: string;
groupId?: string;
};
}
type Listener = (event: RealtimeEvent) => void;
interface RealtimeContextValue {
unreadCount: number;
setUnreadCount: React.Dispatch<React.SetStateAction<number>>;
subscribe: (listener: Listener) => () => void;
connected: boolean;
}
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const { user, token, isPinLocked } = useAuth();
const [unreadCount, setUnreadCount] = React.useState(0);
const [connected, setConnected] = React.useState(false);
const listenersRef = React.useRef(new Set<Listener>());
const socketRef = React.useRef<WebSocket | null>(null);
const reconnectTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const subscribe = React.useCallback((listener: Listener) => {
listenersRef.current.add(listener);
return () => listenersRef.current.delete(listener);
}, []);
const emit = React.useCallback((event: RealtimeEvent) => {
listenersRef.current.forEach((listener) => listener(event));
}, []);
React.useEffect(() => {
if (!user || !token || isPinLocked) {
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
return;
}
let cancelled = false;
function connect() {
if (cancelled) return;
const url = `${WS_URL}?token=${encodeURIComponent(token!)}`;
const socket = new WebSocket(url);
socketRef.current = socket;
socket.onopen = () => setConnected(true);
socket.onclose = () => {
setConnected(false);
if (!cancelled) {
reconnectTimer.current = setTimeout(connect, 3000);
}
};
socket.onerror = () => socket.close();
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string) as RealtimeEvent;
emit(data);
if (data.type === 'family_invite' || (data.type === 'chat_message' && data.payload?.notificationId)) {
setUnreadCount((count) => count + 1);
}
} catch {
// ignore malformed payloads
}
};
}
connect();
return () => {
cancelled = true;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
};
}, [emit, isPinLocked, token, user]);
return (
<RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}>
{children}
</RealtimeContext.Provider>
);
}
export function useRealtime() {
const context = React.useContext(RealtimeContext);
if (!context) {
throw new Error('useRealtime должен использоваться внутри RealtimeProvider');
}
return context;
}

View File

@@ -0,0 +1,16 @@
'use client';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
export function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return <AvatarPrimitive.Root className={cn('relative flex h-12 w-12 shrink-0 overflow-hidden rounded-full', className)} {...props} />;
}
export function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return <AvatarPrimitive.Image className={cn('aspect-square h-full w-full object-cover', className)} {...props} />;
}
export function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return <AvatarPrimitive.Fallback className={cn('flex h-full w-full items-center justify-center rounded-full bg-[#f4f5f8]', className)} {...props} />;
}

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-2xl text-sm font-semibold transition disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-[#20212b] text-white hover:bg-[#11131d]',
secondary: 'bg-[#f4f5f8] text-[#111827] hover:bg-[#eceef4]',
outline: 'border border-[#d8dbe5] bg-transparent hover:bg-[#f4f5f8]',
ghost: 'hover:bg-[#f4f5f8]',
white: 'bg-white text-[#171821] hover:bg-[#f3f4f7]'
},
size: {
default: 'h-11 px-5',
sm: 'h-9 px-3',
lg: 'h-14 px-8',
icon: 'h-11 w-11'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('rounded-[24px] border border-[#eceef4] bg-white text-[#111827] shadow-sm', className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('text-xl font-semibold tracking-tight', className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-sm text-[#667085]', className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-6 pt-0', className)} {...props} />;
}

View File

@@ -0,0 +1,34 @@
'use client';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
export function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return <CommandPrimitive className={cn('flex h-full w-full flex-col overflow-hidden rounded-xl bg-white text-[#111827]', className)} {...props} />;
}
export function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div className="flex h-11 items-center gap-2 border-b border-[#eceef4] px-3">
<Search className="h-4 w-4 shrink-0 text-[#8a8f9e]" />
<CommandPrimitive.Input className={cn('h-10 w-full bg-transparent text-sm outline-none placeholder:text-[#8a8f9e]', className)} {...props} />
</div>
);
}
export function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return <CommandPrimitive.List className={cn('max-h-64 overflow-y-auto overflow-x-hidden p-1', className)} {...props} />;
}
export function CommandEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty className={cn('py-6 text-center text-sm text-[#667085]', className)} {...props} />;
}
export function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return <CommandPrimitive.Group className={cn('overflow-hidden p-1 text-sm', className)} {...props} />;
}
export function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return <CommandPrimitive.Item className={cn('flex cursor-pointer select-none items-center gap-3 rounded-xl px-3 py-2 text-sm outline-none data-[selected=true]:bg-[#f4f5f8]', className)} {...props} />;
}

View File

@@ -0,0 +1,30 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/50 backdrop-blur-[1px]" />
<DialogPrimitive.Content className={cn('fixed left-1/2 top-1/2 z-[201] w-[min(92vw,520px)] -translate-x-1/2 -translate-y-1/2 rounded-[28px] bg-white p-6 shadow-2xl', className)} {...props}>
{children}
<DialogPrimitive.Close className="absolute right-5 top-5 rounded-full p-1 hover:bg-[#f4f5f8]" aria-label="Закрыть">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('mb-5 flex flex-col gap-1', className)} {...props} />;
}
export function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title className={cn('text-xl font-semibold', className)} {...props} />;
}

View File

@@ -0,0 +1,15 @@
'use client';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '@/lib/utils';
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export function DropdownMenuContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return <DropdownMenuPrimitive.Content className={cn('z-50 min-w-48 rounded-2xl border border-[#eceef4] bg-white p-2 shadow-xl', className)} sideOffset={8} {...props} />;
}
export function DropdownMenuItem({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item>) {
return <DropdownMenuPrimitive.Item className={cn('flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-sm outline-none hover:bg-[#f4f5f8]', className)} {...props} />;
}

View File

@@ -0,0 +1,20 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
export function Form({ className, ...props }: React.FormHTMLAttributes<HTMLFormElement>) {
return <form className={cn('space-y-4', className)} {...props} />;
}
export function FormItem({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('space-y-2', className)} {...props} />;
}
export function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return <LabelPrimitive.Root className={cn('text-sm font-medium', className)} {...props} />;
}
export function FormMessage({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-sm text-red-600', className)} {...props} />;
}

View File

@@ -0,0 +1,15 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Input({ className, type, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
type={type}
className={cn(
'h-12 w-full rounded-2xl border border-[#d8dbe5] bg-white px-4 text-sm outline-none transition placeholder:text-[#8a8f9e] focus:border-[#aeb3c2] focus:ring-4 focus:ring-[#eef0f6]',
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { useRef } from 'react';
import { cn } from '@/lib/utils';
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
onComplete?: (value: string) => void;
length?: number;
disabled?: boolean;
className?: string;
}
export function OtpInput({ value, onChange, onComplete, length = 6, disabled = false, className }: OtpInputProps) {
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const digits = Array.from({ length }, (_, index) => value[index] ?? '');
function commit(next: string) {
const clean = next.replace(/\D/g, '').slice(0, length);
onChange(clean);
if (clean.length === length) {
onComplete?.(clean);
}
}
function handleChange(index: number, raw: string) {
const char = raw.replace(/\D/g, '').slice(-1);
if (!char) return;
const chars = value.split('');
chars[index] = char;
const next = chars.join('').slice(0, length);
commit(next);
if (index < length - 1) {
inputsRef.current[index + 1]?.focus();
}
}
function handleKeyDown(index: number, event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Backspace') {
event.preventDefault();
const chars = value.split('');
if (chars[index]) {
chars[index] = '';
onChange(chars.join(''));
} else if (index > 0) {
chars[index - 1] = '';
onChange(chars.join(''));
inputsRef.current[index - 1]?.focus();
}
}
if (event.key === 'ArrowLeft' && index > 0) {
inputsRef.current[index - 1]?.focus();
}
if (event.key === 'ArrowRight' && index < length - 1) {
inputsRef.current[index + 1]?.focus();
}
}
function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
event.preventDefault();
const pasted = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, length);
if (!pasted) return;
commit(pasted);
const focusIndex = Math.min(pasted.length, length - 1);
inputsRef.current[focusIndex]?.focus();
}
return (
<div className={cn('flex justify-between gap-2', className)}>
{digits.map((digit, index) => (
<input
key={index}
ref={(element) => {
inputsRef.current[index] = element;
}}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={1}
disabled={disabled}
value={digit}
onChange={(event) => handleChange(index, event.target.value)}
onKeyDown={(event) => handleKeyDown(index, event)}
onPaste={handlePaste}
className="h-14 w-full rounded-2xl border border-[#555762] bg-transparent text-center text-2xl font-semibold text-white outline-none transition focus:border-[#7b7f8f] focus:ring-4 focus:ring-white/10 disabled:opacity-50"
/>
))}
</div>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { ChevronDown, X } from 'lucide-react';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export interface CountryOption {
id: string;
code: string;
label: string;
maxDigits: number;
/** Path to the country flag asset inside the public directory. */
flagSrc: string;
}
export const FLAG_WIDTH = 24;
export const FLAG_HEIGHT = 18;
export const phoneCountries: CountryOption[] = [
{ id: 'RU', code: '+7', label: 'Россия', maxDigits: 10, flagSrc: '/flags/ru.svg' },
{ id: 'BY', code: '+375', label: 'Беларусь', maxDigits: 9, flagSrc: '/flags/by.svg' },
{ id: 'KZ', code: '+7', label: 'Казахстан', maxDigits: 10, flagSrc: '/flags/kz.svg' }
];
function FlagIcon({ src, className }: { src: string; className?: string }) {
return (
<span
aria-hidden="true"
className={cn('inline-block shrink-0 overflow-hidden rounded-[3px] bg-center bg-no-repeat shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]', className)}
style={{
width: `${FLAG_WIDTH}px`,
height: `${FLAG_HEIGHT}px`,
backgroundImage: `url('${src}')`,
backgroundSize: 'cover'
}}
/>
);
}
export function formatPhoneNumber(digits: string, country: CountryOption) {
const clean = digits.replace(/\D/g, '').slice(0, country.maxDigits);
if (country.code === '+375') {
const part1 = clean.slice(0, 2);
const part2 = clean.slice(2, 5);
const part3 = clean.slice(5, 7);
const part4 = clean.slice(7, 9);
if (clean.length <= 2) return part1 ? `(${part1}` : '';
if (clean.length <= 5) return `(${part1}) ${part2}`;
if (clean.length <= 7) return `(${part1}) ${part2}-${part3}`;
return `(${part1}) ${part2}-${part3}-${part4}`;
}
const part1 = clean.slice(0, 3);
const part2 = clean.slice(3, 6);
const part3 = clean.slice(6, 8);
const part4 = clean.slice(8, 10);
if (clean.length <= 3) return part1 ? `(${part1}` : '';
if (clean.length <= 6) return `(${part1}) ${part2}`;
if (clean.length <= 8) return `(${part1}) ${part2}-${part3}`;
return `(${part1}) ${part2}-${part3}-${part4}`;
}
export function toE164(country: CountryOption, digits: string) {
return `${country.code}${digits.replace(/\D/g, '').slice(0, country.maxDigits)}`;
}
export function PhoneInput({
country,
value,
onCountryChange,
onValueChange,
className
}: {
country: CountryOption;
value: string;
onCountryChange: (country: CountryOption) => void;
onValueChange: (digits: string) => void;
className?: string;
}) {
const maskedValue = formatPhoneNumber(value, country);
return (
<div
className={cn(
'flex h-[58px] w-full items-center rounded-2xl border border-[#555762] bg-transparent text-white transition focus-within:border-[#7b7f8f] focus-within:ring-4 focus-within:ring-white/10',
className
)}
>
<Popover>
<PopoverTrigger asChild>
<button type="button" className="flex h-full shrink-0 items-center gap-2 rounded-l-2xl px-4 text-white outline-none hover:bg-[#2a2c36]" aria-label="Выбрать страну">
<FlagIcon src={country.flagSrc} />
<span className="text-base font-semibold">{country.code}</span>
<ChevronDown className="h-4 w-4 text-[#8f92a0]" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-1">
<Command>
<CommandInput placeholder="Страна или код" />
<CommandList>
<CommandEmpty>Страна не найдена</CommandEmpty>
<CommandGroup>
{phoneCountries.map((item) => (
<CommandItem
key={item.id}
value={`${item.label} ${item.code}`}
onSelect={() => {
onCountryChange(item);
onValueChange(value.replace(/\D/g, '').slice(0, item.maxDigits));
}}
>
<FlagIcon src={item.flagSrc} />
<span className="flex-1">{item.label}</span>
<span className="font-semibold text-[#667085]">{item.code}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<input
className="h-full min-w-0 flex-1 bg-transparent px-1 text-lg text-white outline-none placeholder:text-[#8f92a0]"
inputMode="tel"
placeholder={country.code === '+375' ? '(29) 123-45-67' : '(999) 123-45-67'}
value={maskedValue}
onChange={(event) => onValueChange(event.target.value.replace(/\D/g, '').slice(0, country.maxDigits))}
required
/>
{value ? (
<button type="button" className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#8f92a0] hover:bg-[#2a2c36] hover:text-white" onClick={() => onValueChange('')} aria-label="Очистить телефон">
<X className="h-4 w-4" />
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export function PopoverContent({ className, align = 'start', sideOffset = 8, ...props }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={sideOffset}
className={cn('z-50 w-72 rounded-2xl border border-[#eceef4] bg-white p-2 text-[#111827] shadow-xl outline-none', className)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Table({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
return <table className={cn('w-full caption-bottom text-sm', className)} {...props} />;
}
export function TableHeader({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn('[&_tr]:border-b', className)} {...props} />;
}
export function TableBody({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} />;
}
export function TableRow({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn('border-b border-[#eceef4] transition-colors hover:bg-[#f8f9fb]', className)} {...props} />;
}
export function TableHead({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
return <th className={cn('h-11 px-4 text-left align-middle font-medium text-[#667085]', className)} {...props} />;
}
export function TableCell({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn('p-4 align-middle', className)} {...props} />;
}

View File

@@ -0,0 +1,23 @@
'use client';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return <TabsPrimitive.List className={cn('inline-flex rounded-2xl bg-[#090a0f] p-0.5', className)} {...props} />;
}
export function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn('rounded-[15px] px-9 py-3 text-sm font-semibold text-[#9a9dab] data-[state=active]:bg-[#22232c] data-[state=active]:text-white', className)}
{...props}
/>
);
}
export function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn('mt-4', className)} {...props} />;
}

View File

@@ -0,0 +1,17 @@
'use client';
import * as ToastPrimitive from '@radix-ui/react-toast';
import { cn } from '@/lib/utils';
export const ToastProvider = ToastPrimitive.Provider;
export const ToastViewport = (props: React.ComponentProps<typeof ToastPrimitive.Viewport>) => (
<ToastPrimitive.Viewport className="fixed bottom-4 right-4 z-50 flex max-w-sm flex-col gap-2" {...props} />
);
export function Toast({ className, ...props }: React.ComponentProps<typeof ToastPrimitive.Root>) {
return <ToastPrimitive.Root className={cn('rounded-2xl bg-[#20212b] p-4 text-sm text-white shadow-xl', className)} {...props} />;
}
export function ToastTitle({ className, ...props }: React.ComponentProps<typeof ToastPrimitive.Title>) {
return <ToastPrimitive.Title className={cn('font-semibold', className)} {...props} />;
}

View File

@@ -0,0 +1,39 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '@/components/id/auth-provider';
import { apiFetch } from '@/lib/api';
interface AvatarAccessResponse {
accessUrl: string;
expiresAt: string;
}
export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) {
const { isPinLocked } = useAuth();
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!userId || !token || !hasAvatar || isPinLocked) {
setAvatarUrl(null);
return;
}
try {
const response = await apiFetch<AvatarAccessResponse>(`/media/avatars/${userId}/url`, {}, token);
setAvatarUrl(response.accessUrl);
} catch {
setAvatarUrl(null);
}
}, [hasAvatar, isPinLocked, token, userId]);
useEffect(() => {
void refresh();
if (!hasAvatar) return;
const timer = window.setInterval(() => {
void refresh();
}, 14 * 60 * 1000);
return () => window.clearInterval(timer);
}, [hasAvatar, refresh]);
return { avatarUrl, refreshAvatarUrl: refresh };
}

View File

@@ -0,0 +1,25 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/components/id/auth-provider';
export function useRequireAuth() {
const router = useRouter();
const pathname = usePathname();
const { user, isLoading, isPinLocked, hasStoredSession } = useAuth();
useEffect(() => {
if (isLoading) return;
if (user || isPinLocked || hasStoredSession) return;
if (pathname.startsWith('/auth/')) return;
router.replace('/auth/login');
}, [hasStoredSession, isLoading, isPinLocked, pathname, router, user]);
return {
user,
isLoading,
isPinLocked,
isReady: !isLoading && Boolean(user || isPinLocked || hasStoredSession)
};
}

View File

@@ -0,0 +1,28 @@
export type AddressLabel = 'HOME' | 'WORK' | 'OTHER';
export const ADDRESS_LABELS: Record<AddressLabel, { title: string; shortTitle: string }> = {
HOME: { title: 'Дом', shortTitle: 'Дом' },
WORK: { title: 'Работа', shortTitle: 'Работа' },
OTHER: { title: 'Другой адрес', shortTitle: 'Другие' }
};
export function formatAddressLine(address: {
fullAddress?: string | null;
city?: string;
street?: string;
house?: string;
apartment?: string | null;
}) {
if (address.fullAddress?.trim()) return address.fullAddress.trim();
const parts = [`г. ${address.city}`, `${address.street}, ${address.house}`];
if (address.apartment?.trim()) parts.push(`кв. ${address.apartment.trim()}`);
return parts.filter(Boolean).join(', ');
}
export function getPrimaryAddress(addresses: { label: string }[], label: AddressLabel) {
return addresses.find((item) => item.label === label) ?? null;
}
export function getOtherAddresses<T extends { label: string }>(addresses: T[]) {
return addresses.filter((item) => item.label === 'OTHER');
}

582
apps/frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,582 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
export interface PublicUser {
id: string;
email?: string | null;
phone?: string | null;
backupEmail?: string | null;
backupPhone?: string | null;
displayName: string;
username?: string | null;
avatarUrl?: string | null;
hasAvatar?: boolean;
isSuperAdmin: boolean;
status: string;
roles?: string[];
permissions?: string[];
canAccessAdmin?: boolean;
canManageRoles?: boolean;
canManageOAuth?: boolean;
canManageUsers?: boolean;
canManageSettings?: boolean;
canViewUsers?: boolean;
canViewUserDocuments?: boolean;
}
export interface AdminUser {
id: string;
email?: string | null;
phone?: string | null;
displayName: string;
username?: string | null;
isSuperAdmin: boolean;
status: string;
createdAt: string;
roles: string[];
}
export interface AdminRole {
id: string;
slug: string;
name: string;
description?: string;
permissions: { id: string; slug: string; name: string; description?: string }[];
}
export interface AdminPermission {
id: string;
slug: string;
name: string;
description?: string;
}
export interface OAuthScope {
id: string;
slug: string;
name: string;
description?: string;
}
export interface OAuthClient {
id: string;
name: string;
clientId: string;
redirectUris: string[];
scopes: string[];
isActive: boolean;
type: string;
clientSecret?: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresAt: string;
pinVerified: boolean;
user: PublicUser;
sessionId: string;
}
export interface PasswordlessAuthResponse {
requiresPassword: boolean;
tempAuthToken?: string;
auth?: AuthTokens;
}
export interface LoginMethod {
kind: 'password' | 'otp';
channel: string;
masked: string;
}
export interface IdentifyResponse {
exists: boolean;
hasPassword: boolean;
isPinEnabled: boolean;
methods?: LoginMethod[];
}
export interface OtpSendResponse {
sent: boolean;
expiresAt: string;
maskedTarget: string;
}
export interface PinVerificationResponse {
accessToken: string;
pinVerified: boolean;
}
export interface RefreshSessionResponse {
requiresPin: boolean;
accessToken?: string;
sessionId?: string;
pinVerified: boolean;
user?: PublicUser;
}
export interface AuthSessionResponse {
requiresPin: boolean;
sessionId?: string | null;
pinVerified: boolean;
user: PublicUser;
}
export const AUTH_TOKEN_KEY = 'lendry_access_token';
export const AUTH_REFRESH_KEY = 'lendry_refresh_token';
export const AUTH_SESSION_KEY = 'lendry_session_id';
export const AUTH_USER_CACHE_KEY = 'lendry_cached_user';
export class ApiError extends Error {
status: number;
code?: string;
sessionId?: string;
constructor(message: string, status: number, code?: string, sessionId?: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.sessionId = sessionId;
}
}
type PinRequiredHandler = (sessionId: string) => void;
export function setPinRequiredHandler(handler: PinRequiredHandler | null) {
pinRequiredHandler = handler;
if (!handler) {
pinNotificationSent = false;
}
}
interface ErrorBody {
message?: string | string[];
error?: string;
code?: string;
sessionId?: string;
}
async function parseErrorResponse(response: Response): Promise<ApiError> {
try {
const body = (await response.clone().json()) as ErrorBody;
const message = Array.isArray(body.message)
? body.message.join('\n')
: (body.message ?? body.error ?? 'Не удалось выполнить запрос');
const pinRequired =
body.code === 'PIN_REQUIRED' ||
(response.status === 403 && typeof message === 'string' && message.toLowerCase().includes('pin'));
return new ApiError(message, response.status, pinRequired ? 'PIN_REQUIRED' : body.code, body.sessionId);
} catch {
return new ApiError('Не удалось выполнить запрос', response.status);
}
}
export function isPinRequiredError(error: unknown): error is ApiError {
return error instanceof ApiError && error.code === 'PIN_REQUIRED';
}
export function getApiErrorMessage(error: unknown, fallback: string): string | null {
if (isPinRequiredError(error)) return null;
if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) {
return null;
}
return error instanceof Error ? error.message : fallback;
}
let pinRequiredHandler: PinRequiredHandler | null = null;
let pinNotificationSent = false;
export function resetPinRequiredNotification() {
pinNotificationSent = false;
}
function notifyPinRequired(sessionId: string) {
if (!pinRequiredHandler || pinNotificationSent) return;
pinNotificationSent = true;
pinRequiredHandler(sessionId);
}
export async function fetchAuthSession(token?: string | null): Promise<AuthSessionResponse> {
return apiFetch<AuthSessionResponse>('/auth/session', {}, token);
}
export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
if (typeof window === 'undefined') {
throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH');
}
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
const sessionId = window.localStorage.getItem(AUTH_SESSION_KEY);
if (!refreshToken) {
throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH');
}
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined })
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
return response.json() as Promise<RefreshSessionResponse>;
}
async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
try {
const refreshed = await refreshAuthSession();
if (refreshed.requiresPin) {
notifyPinRequired(refreshed.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return refreshed;
}
if (refreshed.accessToken) {
window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken);
if (refreshed.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId);
}
if (refreshed.user) {
window.dispatchEvent(new CustomEvent('lendry:auth-refreshed', { detail: refreshed }));
}
}
return refreshed;
} catch {
return null;
}
}
export interface UserDocument {
id: string;
userId: string;
type: string;
number: string;
issuedAt?: string;
expiresAt?: string;
metadataJson?: string;
}
export interface SystemSetting {
id: string;
key: string;
value: string;
description?: string | null;
isSecret: boolean;
}
export interface SocialProvider {
id: string;
providerName: string;
clientId: string;
isEnabled: boolean;
}
export interface ActiveDevice {
id: string;
name: string;
type: string;
lastIp?: string | null;
lastSeenAt: string;
trusted: boolean;
}
export interface SignInEvent {
id: string;
success: boolean;
reason?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
createdAt: string;
}
export interface ActiveSession {
id: string;
userId: string;
deviceId?: string | null;
pinVerified: boolean;
status: string;
ipAddress?: string | null;
userAgent?: string | null;
expiresAt: string;
createdAt: string;
updatedAt: string;
}
async function parseError(response: Response): Promise<string> {
const error = await parseErrorResponse(response);
return error.message;
}
export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | 'value' | 'description' | 'isSecret'>) {
return {
key: setting.key,
value: setting.value,
description: setting.description ?? undefined,
isSecret: setting.isSecret
};
}
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null);
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
...options.headers
}
});
if (!response.ok) {
const error = await parseErrorResponse(response);
if (error.code === 'PIN_REQUIRED') {
notifyPinRequired(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
throw error;
}
if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') {
const refreshed = await trySilentTokenRefresh();
if (refreshed?.accessToken) {
return apiFetch<T>(path, options, refreshed.accessToken, false);
}
if (refreshed?.requiresPin) {
throw error;
}
}
throw error;
}
return response.json() as Promise<T>;
}
export function getDeviceFingerprint(): string {
if (typeof window === 'undefined') return 'server';
const key = 'lendry_device_fingerprint';
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const value = crypto.randomUUID();
window.localStorage.setItem(key, value);
return value;
}
export interface MediaAccessResponse {
accessUrl: string;
expiresAt: string;
}
export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, token?: string | null) {
const query = new URLSearchParams({ storageKey });
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
}
export async function softDeleteProfile(userId: string, token?: string | null) {
return apiFetch<{ success: boolean }>(`/profile/users/${userId}/self-delete`, { method: 'POST' }, token);
}
export interface UserAddress {
id: string;
userId: string;
label: 'HOME' | 'WORK' | 'OTHER';
city: string;
street: string;
house: string;
apartment?: string;
comment?: string;
latitude?: number;
longitude?: number;
fullAddress?: string;
createdAt: string;
updatedAt: string;
}
export async function fetchUserAddresses(userId: string, token?: string | null) {
return apiFetch<{ addresses?: UserAddress[] }>(`/addresses/users/${userId}`, {}, token);
}
export async function upsertUserAddress(
userId: string,
body: {
addressId?: string;
label: UserAddress['label'];
city: string;
street: string;
house: string;
apartment?: string;
comment?: string;
latitude?: number;
longitude?: number;
fullAddress?: string;
},
token?: string | null
) {
return apiFetch<UserAddress>(`/addresses/users/${userId}`, { method: 'POST', body: JSON.stringify(body) }, token);
}
export async function deleteUserAddress(userId: string, addressId: string, token?: string | null) {
return apiFetch<{ count: number }>(`/addresses/users/${userId}/${addressId}`, { method: 'DELETE' }, token);
}
export interface FamilyMember {
id: string;
userId: string;
role: string;
displayName: string;
hasAvatar: boolean;
}
export interface FamilyGroup {
id: string;
ownerId: string;
name: string;
hasAvatar: boolean;
members?: FamilyMember[];
}
export interface FamilyInvite {
id: string;
groupId: string;
groupName: string;
inviterId: string;
inviterName: string;
inviteeUserId?: string;
targetEmail?: string;
targetPhone?: string;
status: string;
expiresAt: string;
}
export interface AppNotification {
id: string;
type: string;
title: string;
message: string;
payloadJson?: string;
isRead: boolean;
createdAt: string;
}
export interface ChatPollOption {
id: string;
text: string;
voteCount: number;
votedByMe: boolean;
}
export interface ChatMessage {
id: string;
roomId: string;
senderId: string;
senderName: string;
senderHasAvatar: boolean;
type: string;
content?: string;
replyToId?: string;
storageKey?: string;
mimeType?: string;
metadataJson?: string;
createdAt: string;
poll?: {
id: string;
question: string;
allowsMultiple: boolean;
isAnonymous: boolean;
options: ChatPollOption[];
};
}
export interface ChatRoom {
id: string;
groupId: string;
type: string;
name: string;
hasAvatar: boolean;
updatedAt: string;
members: Array<{ id: string; userId: string; displayName: string; hasAvatar: boolean; notificationsMuted: boolean }>;
lastMessage?: ChatMessage;
}
export async function fetchFamilyGroups(userId: string, token?: string | null) {
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, token);
}
export async function fetchFamilyGroup(groupId: string, token?: string | null) {
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, {}, token);
}
export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) {
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token);
}
export async function sendFamilyInvite(groupId: string, target: string, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify({ target }) }, token);
}
export async function fetchFamilyInvites(token?: string | null) {
return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token);
}
export async function respondFamilyInvite(inviteId: string, accept: boolean, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/invites/${inviteId}/respond`, { method: 'POST', body: JSON.stringify({ accept }) }, token);
}
export async function fetchNotifications(token?: string | null, unreadOnly = false) {
return apiFetch<{ notifications?: AppNotification[] }>(`/notifications?unreadOnly=${unreadOnly}`, {}, token);
}
export async function fetchUnreadNotificationCount(token?: string | null) {
return apiFetch<{ count: number }>('/notifications/unread-count', {}, token);
}
export async function markNotificationRead(notificationId: string, token?: string | null) {
return apiFetch(`/notifications/${notificationId}/read`, { method: 'PATCH', body: '{}' }, token);
}
export async function markAllNotificationsRead(token?: string | null) {
return apiFetch('/notifications/read-all', { method: 'POST' }, token);
}
export async function fetchChatRooms(groupId: string, token?: string | null) {
return apiFetch<{ rooms?: ChatRoom[] }>(`/chat/groups/${groupId}/rooms`, {}, token);
}
export async function createChatRoom(groupId: string, name: string, memberUserIds: string[], token?: string | null) {
return apiFetch<ChatRoom>(`/chat/groups/${groupId}/rooms`, {
method: 'POST',
body: JSON.stringify({ name, memberUserIds })
}, token);
}
export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) {
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token);
}
export async function sendChatMessage(
roomId: string,
body: {
type: string;
content?: string;
replyToId?: string;
storageKey?: string;
mimeType?: string;
metadataJson?: string;
poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean };
},
token?: string | null
) {
return apiFetch<ChatMessage>(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token);
}
export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token);
}
export async function setChatRoomMuted(roomId: string, muted: boolean, token?: string | null) {
return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token);
}
export const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws';

View File

@@ -0,0 +1,41 @@
export async function fetchAuthenticatedMediaBlob(accessUrl: string, token: string) {
const response = await fetch(accessUrl, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Не удалось загрузить файл');
}
return response.blob();
}
export function createBlobObjectUrl(blob: Blob) {
return URL.createObjectURL(blob);
}
export function revokeBlobObjectUrl(url?: string) {
if (url?.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
}
export function triggerBlobDownload(blob: Blob, fileName: string) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = fileName;
anchor.rel = 'noopener';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
export function mediaExpiresAtMs(expiresAt?: string) {
if (!expiresAt) return Date.now() + 14 * 60_000;
const parsed = Date.parse(expiresAt);
return Number.isFinite(parsed) ? parsed : Date.now() + 14 * 60_000;
}
export function shouldRefreshMedia(expiresAtMs: number, bufferMs = 60_000) {
return expiresAtMs - Date.now() <= bufferMs;
}

View File

@@ -0,0 +1,115 @@
export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'FILE';
const EXTENSION_TO_MIME: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
heic: 'image/heic',
heif: 'image/heif',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
ogg: 'audio/ogg',
opus: 'audio/ogg',
webm: 'audio/webm',
wav: 'audio/wav',
aac: 'audio/aac',
flac: 'audio/flac',
mp4: 'video/mp4',
mov: 'video/quicktime',
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
txt: 'text/plain',
csv: 'text/csv',
zip: 'application/zip',
rar: 'application/vnd.rar',
'7z': 'application/x-7z-compressed',
json: 'application/json',
xml: 'application/xml'
};
const MIME_ALIASES: Record<string, string> = {
'audio/x-m4a': 'audio/mp4',
'audio/x-aac': 'audio/aac',
'audio/x-wav': 'audio/wav',
'audio/x-flac': 'audio/flac',
'audio/x-mpeg': 'audio/mpeg',
'audio/mp3': 'audio/mpeg',
'image/jpg': 'image/jpeg',
'image/pjpeg': 'image/jpeg',
'application/x-pdf': 'application/pdf',
'application/x-zip-compressed': 'application/zip'
};
function extractFileExtension(value?: string | null) {
if (!value) return '';
const clean = value.split('?')[0]?.split('#')[0] ?? value;
const parts = clean.split('.');
if (parts.length < 2) return '';
return parts.pop()?.trim().toLowerCase() ?? '';
}
export function resolveChatContentType(file: Pick<File, 'type' | 'name'>) {
const raw = (file.type ?? '').trim().toLowerCase();
const base = raw.split(';')[0]?.trim() ?? '';
const aliased = MIME_ALIASES[base] ?? base;
if (aliased && aliased !== 'application/octet-stream') {
return aliased;
}
const extension = extractFileExtension(file.name);
if (extension && EXTENSION_TO_MIME[extension]) {
return EXTENSION_TO_MIME[extension];
}
return aliased || 'application/octet-stream';
}
export function inferChatMessageType(contentType: string, options?: { voice?: boolean }): ChatAttachmentKind {
const normalized = resolveChatContentType({ type: contentType, name: '' });
if (normalized.startsWith('image/')) return 'IMAGE';
if (normalized.startsWith('video/')) return 'FILE';
if (normalized.startsWith('audio/')) {
return options?.voice ? 'VOICE' : 'AUDIO';
}
return 'FILE';
}
export function detectChatMessageType(file: File, options?: { voice?: boolean }) {
const contentType = resolveChatContentType(file);
return inferChatMessageType(contentType, options);
}
export function formatFileSize(bytes?: number) {
if (!bytes || bytes <= 0) return '';
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
export function parseMessageMetadata(metadataJson?: string) {
if (!metadataJson) return {} as { fileName?: string; fileSize?: number; durationMs?: number };
try {
return JSON.parse(metadataJson) as { fileName?: string; fileSize?: number; durationMs?: number };
} catch {
return {};
}
}
export function fileIconLabel(fileName?: string, mimeType?: string) {
const extension = extractFileExtension(fileName);
if (extension === 'pdf' || mimeType === 'application/pdf') return 'PDF';
if (['doc', 'docx'].includes(extension)) return 'DOC';
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'XLS';
if (['ppt', 'pptx'].includes(extension)) return 'PPT';
if (['zip', 'rar', '7z'].includes(extension)) return 'ZIP';
if (extension) return extension.toUpperCase().slice(0, 4);
return 'FILE';
}

View File

@@ -0,0 +1,308 @@
import type { LucideIcon } from 'lucide-react';
import {
Baby,
Building2,
Car,
CreditCard,
FileText,
HeartPulse,
Plane,
Stethoscope,
Wallet
} from 'lucide-react';
export type DocumentTypeCode =
| 'PASSPORT_RF'
| 'FOREIGN_PASSPORT'
| 'BIRTH_CERTIFICATE'
| 'DRIVER_LICENSE'
| 'VEHICLE_REGISTRATION'
| 'OMS'
| 'DMS'
| 'INN'
| 'SNILS';
export type FieldKind = 'text' | 'date' | 'gender' | 'checkbox' | 'textarea' | 'categories';
export interface DocumentFieldDef {
key: string;
label: string;
kind: FieldKind;
placeholder?: string;
latinKey?: string;
latinLabel?: string;
}
export interface DocumentTypeDef {
type: DocumentTypeCode;
label: string;
shortLabel: string;
category: 'personal' | 'transport' | 'health' | 'finance';
icon: LucideIcon;
accent: string;
numberKey: string;
fields: DocumentFieldDef[];
}
export const DOCUMENT_CATEGORIES = [
{ id: 'personal' as const, title: 'Личные документы' },
{ id: 'transport' as const, title: 'Транспорт' },
{ id: 'health' as const, title: 'Здоровье' },
{ id: 'finance' as const, title: 'Финансы' }
];
const baseNameFields: DocumentFieldDef[] = [
{ key: 'lastName', label: 'Фамилия', kind: 'text', placeholder: 'Фамилия' },
{ key: 'firstName', label: 'Имя', kind: 'text', placeholder: 'Имя' },
{ key: 'middleName', label: 'Отчество', kind: 'text', placeholder: 'Отчество' },
{ key: 'noMiddleName', label: 'Нет отчества', kind: 'checkbox' }
];
const latinNameFields: DocumentFieldDef[] = [
{ key: 'lastName', label: 'Фамилия', kind: 'text', placeholder: 'Фамилия', latinKey: 'lastNameLatin', latinLabel: 'Фамилия латинскими буквами' },
{ key: 'firstName', label: 'Имя', kind: 'text', placeholder: 'Имя', latinKey: 'firstNameLatin', latinLabel: 'Имя латинскими буквами' },
{ key: 'middleName', label: 'Отчество', kind: 'text', placeholder: 'Отчество', latinKey: 'middleNameLatin', latinLabel: 'Отчество латинскими буквами' },
{ key: 'noMiddleName', label: 'Нет отчества', kind: 'checkbox' }
];
export const DOCUMENT_TYPES: DocumentTypeDef[] = [
{
type: 'PASSPORT_RF',
label: 'Паспорт РФ',
shortLabel: 'Паспорт РФ',
category: 'personal',
icon: FileText,
accent: 'text-red-500 bg-red-50',
numberKey: 'seriesNumber',
fields: [
{ key: 'seriesNumber', label: 'Серия и номер', kind: 'text', placeholder: '0000 000000' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text', placeholder: 'Место рождения' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'issuedBy', label: 'Кем выдан', kind: 'text', placeholder: 'Кем выдан' },
{ key: 'departmentCode', label: 'Код подразделения', kind: 'text', placeholder: '000-000' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
{ key: 'registrationAddress', label: 'Адрес регистрации', kind: 'text', placeholder: 'Адрес регистрации' }
]
},
{
type: 'FOREIGN_PASSPORT',
label: 'Загран',
shortLabel: 'Загран',
category: 'personal',
icon: Plane,
accent: 'text-red-500 bg-red-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'Номер', kind: 'text', placeholder: 'Номер' },
...latinNameFields,
{ key: 'citizenship', label: 'Гражданство', kind: 'text', placeholder: 'Гражданство', latinKey: 'citizenshipLatin', latinLabel: 'Гражданство латинскими буквами' },
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text', placeholder: 'Место рождения', latinKey: 'birthPlaceLatin', latinLabel: 'Место рождения латинскими буквами' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
{ key: 'expiresAt', label: 'Дата окончания срока действия', kind: 'date' }
]
},
{
type: 'BIRTH_CERTIFICATE',
label: 'Свидетельство о рождении',
shortLabel: 'Св-во о рождении',
category: 'personal',
icon: Baby,
accent: 'text-red-500 bg-red-50',
numberKey: 'seriesNumber',
fields: [
{ key: 'series', label: 'Серия', kind: 'text', placeholder: 'Серия' },
{ key: 'number', label: 'Номер', kind: 'text', placeholder: 'Номер' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text' },
{ key: 'recordNumber', label: 'Номер записи акта о рождении', kind: 'text' },
{ key: 'fatherLastName', label: 'Фамилия отца', kind: 'text', placeholder: 'Фамилия' },
{ key: 'fatherFirstName', label: 'Имя отца', kind: 'text', placeholder: 'Имя' },
{ key: 'fatherMiddleName', label: 'Отчество отца', kind: 'text', placeholder: 'Отчество' },
{ key: 'fatherNoMiddleName', label: 'Нет отчества (отец)', kind: 'checkbox' },
{ key: 'fatherCitizenship', label: 'Гражданство отца', kind: 'text' },
{ key: 'fatherBirthDate', label: 'Дата рождения отца', kind: 'date' }
]
},
{
type: 'DRIVER_LICENSE',
label: 'ВУ',
shortLabel: 'ВУ',
category: 'transport',
icon: CreditCard,
accent: 'text-violet-500 bg-violet-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'Номер', kind: 'text' },
...latinNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text', latinKey: 'birthPlaceLatin', latinLabel: 'Место рождения латинскими буквами' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
{ key: 'expiresAt', label: 'Действует до', kind: 'date' },
{ key: 'issuedBy', label: 'Кем выдано', kind: 'text', latinKey: 'issuedByLatin', latinLabel: 'Кем выдано латинскими буквами' },
{ key: 'issuePlace', label: 'Место выдачи', kind: 'text', latinKey: 'issuePlaceLatin', latinLabel: 'Место выдачи латинскими буквами' },
{ key: 'categories', label: 'Категории', kind: 'categories' },
{ key: 'specialMarks', label: 'Особые отметки', kind: 'textarea' }
]
},
{
type: 'VEHICLE_REGISTRATION',
label: 'СТС / СРТС',
shortLabel: 'СТС / СРТС',
category: 'transport',
icon: Car,
accent: 'text-violet-500 bg-violet-50',
numberKey: 'seriesNumber',
fields: [
{ key: 'seriesNumber', label: 'Серия и номер', kind: 'text' },
{ key: 'plateNumber', label: 'Регистрационный знак', kind: 'text', placeholder: 'X000XX00' },
{ key: 'vin', label: 'Идентификационный номер (VIN)', kind: 'text' },
{ key: 'makeModel', label: 'Марка, модель', kind: 'text', latinKey: 'makeModelLatin', latinLabel: 'Марка, модель латинскими буквами' },
{ key: 'vehicleType', label: 'Тип ТС', kind: 'text' },
{ key: 'vehicleCategory', label: 'Категория ТС', kind: 'text' },
{ key: 'year', label: 'Год выпуска', kind: 'text' },
{ key: 'chassisNumber', label: 'Номер шасси (рамы)', kind: 'text' },
{ key: 'bodyNumber', label: 'Номер кузова (кабины, прицепа)', kind: 'text' },
{ key: 'color', label: 'Цвет', kind: 'text' },
{ key: 'enginePower', label: 'Мощность двигателя, кВт/л.с.', kind: 'text' },
{ key: 'ecoClass', label: 'Экологический класс', kind: 'text' },
{ key: 'maxMass', label: 'Разрешенная максимальная масса, кг', kind: 'text' },
{ key: 'curbWeight', label: 'Масса в снаряженном состоянии, кг', kind: 'text' },
{ key: 'tempRegistration', label: 'Срок временной регистрации', kind: 'text' },
{ key: 'pts', label: 'ПТС', kind: 'text', placeholder: '77TX000006 или 123456789012345' },
...latinNameFields.slice(0, 3),
{ key: 'registrationAddress', label: 'Адрес регистрации', kind: 'text' },
{ key: 'departmentCode', label: 'Код подразделения (выдано ГИБДД)', kind: 'text' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
{ key: 'specialMarks', label: 'Особые отметки', kind: 'textarea' }
]
},
{
type: 'OMS',
label: 'ОМС',
shortLabel: 'ОМС',
category: 'health',
icon: Stethoscope,
accent: 'text-sky-500 bg-sky-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'Номер', kind: 'text' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'blankSeriesNumber', label: 'Серия и номер бланка', kind: 'text' }
]
},
{
type: 'DMS',
label: 'ДМС',
shortLabel: 'ДМС',
category: 'health',
icon: HeartPulse,
accent: 'text-sky-500 bg-sky-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'Номер', kind: 'text' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'validFrom', label: 'Действует с', kind: 'date' },
{ key: 'validTo', label: 'Действует до', kind: 'date' },
{ key: 'insurer', label: 'Страхователь', kind: 'text' }
]
},
{
type: 'INN',
label: 'ИНН',
shortLabel: 'ИНН',
category: 'finance',
icon: Building2,
accent: 'text-emerald-500 bg-emerald-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'ИНН', kind: 'text' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'issuedBy', label: 'Орган, выдавший документ', kind: 'text' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' }
]
},
{
type: 'SNILS',
label: 'СНИЛС',
shortLabel: 'СНИЛС',
category: 'finance',
icon: Wallet,
accent: 'text-emerald-500 bg-emerald-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'СНИЛС', kind: 'text' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'registeredAt', label: 'Дата регистрации', kind: 'date' }
]
}
];
export const QUICK_DOCUMENT_TYPES = ['PASSPORT_RF', 'DRIVER_LICENSE', 'FOREIGN_PASSPORT'] as const;
export const LICENSE_CATEGORIES = ['A', 'B', 'C', 'D', 'A1', 'B1', 'C1', 'D1', 'M', 'BE', 'CE', 'DE', 'Tm', 'Tb', 'C1E', 'D1E'];
export function getDocumentType(type: string) {
return DOCUMENT_TYPES.find((item) => item.type === type);
}
export function buildDocumentNumber(type: DocumentTypeDef, values: Record<string, string>) {
if (type.type === 'BIRTH_CERTIFICATE') {
return [values.series, values.number].filter(Boolean).join(' ');
}
if (type.type === 'PASSPORT_RF' || type.type === 'VEHICLE_REGISTRATION') {
return values.seriesNumber || values.number || '—';
}
return values[type.numberKey] || '—';
}
export function parseMetadata(metadataJson?: string) {
if (!metadataJson) return {} as Record<string, unknown>;
try {
return JSON.parse(metadataJson) as Record<string, unknown>;
} catch {
return {} as Record<string, unknown>;
}
}
export function parseDocumentPhotos(metadata: Record<string, unknown>): string[] {
const keys = metadata.photoStorageKeys;
if (Array.isArray(keys)) {
return keys.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
if (typeof metadata.photoStorageKey === 'string' && metadata.photoStorageKey) {
return [metadata.photoStorageKey];
}
return [];
}
export function withDocumentPhotos(metadata: Record<string, unknown>, photoStorageKeys: string[]) {
const next: Record<string, unknown> = { ...metadata, photoStorageKeys };
delete next.photoStorageKey;
return next;
}
/** Берёт самый свежий документ каждого типа (список с бэкенда отсортирован по updatedAt desc). */
export function indexDocumentsByType<T extends { type: string }>(documents: T[]): Map<string, T> {
const map = new Map<string, T>();
for (const document of documents) {
if (!map.has(document.type)) {
map.set(document.type, document);
}
}
return map;
}

View File

@@ -0,0 +1,76 @@
export interface ParsedAddress {
city: string;
street: string;
house: string;
fullAddress: string;
}
const NOMINATIM_HEADERS = {
'User-Agent': 'LendryID/1.0 (contact@lendry.ru)',
Accept: 'application/json'
};
export async function reverseGeocode(lat: number, lng: number): Promise<ParsedAddress | null> {
try {
const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(String(lat))}&lon=${encodeURIComponent(String(lng))}&accept-language=ru`;
const response = await fetch(url, { headers: NOMINATIM_HEADERS });
if (!response.ok) return null;
const data = (await response.json()) as {
display_name?: string;
address?: Record<string, string | undefined>;
};
const addr = data.address ?? {};
const city = addr.city || addr.town || addr.village || addr.municipality || addr.state || '';
const street = addr.road || addr.pedestrian || addr.footway || addr.neighbourhood || '';
const house = addr.house_number || '';
return {
city,
street,
house,
fullAddress: data.display_name ?? formatParsed(city, street, house)
};
} catch {
return null;
}
}
export async function forwardGeocode(query: string): Promise<{ lat: number; lng: number; parsed: ParsedAddress } | null> {
const trimmed = query.trim();
if (!trimmed) return null;
try {
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&q=${encodeURIComponent(trimmed)}&limit=1&accept-language=ru`;
const response = await fetch(url, { headers: NOMINATIM_HEADERS });
if (!response.ok) return null;
const data = (await response.json()) as Array<{
lat: string;
lon: string;
display_name?: string;
address?: Record<string, string | undefined>;
}>;
const first = data[0];
if (!first) return null;
const addr = first.address ?? {};
const city = addr.city || addr.town || addr.village || addr.municipality || '';
const street = addr.road || addr.pedestrian || '';
const house = addr.house_number || '';
return {
lat: Number(first.lat),
lng: Number(first.lon),
parsed: {
city,
street,
house,
fullAddress: first.display_name ?? formatParsed(city, street, house)
}
};
} catch {
return null;
}
}
function formatParsed(city: string, street: string, house: string) {
const parts = [];
if (city) parts.push(`г. ${city}`);
if (street || house) parts.push([street, house].filter(Boolean).join(', '));
return parts.join(', ');
}

View File

@@ -0,0 +1,62 @@
export type SystemSettingFieldType = 'text' | 'number' | 'boolean';
export interface SystemSettingMeta {
key: string;
label: string;
group: string;
type: SystemSettingFieldType;
unit?: string;
hint?: string;
}
export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
general: 'Проект и брендинг',
pin: 'PIN-код и блокировка сессии',
auth: 'Аутентификация и регистрация',
ldap: 'LDAP / Active Directory',
limits: 'Лимиты и ограничения',
media: 'Медиа и файлы'
};
export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
{ key: 'PROJECT_NAME', label: 'Название проекта', group: 'general', type: 'text', hint: 'Отображается в меню, заголовке и боковой панели' },
{ key: 'PROJECT_TAGLINE', label: 'Слоган проекта', group: 'general', type: 'text' },
{ key: 'PROJECT_DOMAIN', label: 'Домен IdP', group: 'general', type: 'text' },
{ key: 'PIN_LOCK_TIMEOUT_MINUTES', label: 'Таймаут блокировки PIN', group: 'pin', type: 'number', unit: 'мин' },
{ key: 'PIN_DELETE_GRACE_MINUTES', label: 'Задержка удаления PIN', group: 'pin', type: 'number', unit: 'мин', hint: 'Сколько ждать после запроса удаления PIN-кода' },
{ key: 'PIN_REQUIRE_ON_DELETE', label: 'Требовать PIN при удалении', group: 'pin', type: 'boolean' },
{ key: 'PIN_MIN_LENGTH', label: 'Мин. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
{ key: 'PIN_MAX_LENGTH', label: 'Макс. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
{ key: 'OTP_EXPIRY_MINUTES', label: 'Срок жизни OTP', group: 'auth', type: 'number', unit: 'мин' },
{ key: 'OTP_MAX_ATTEMPTS', label: 'Попыток ввода OTP', group: 'auth', type: 'number' },
{ key: 'SESSION_REFRESH_DAYS', label: 'Срок refresh-токена', group: 'auth', type: 'number', unit: 'дн' },
{ key: 'PASSWORD_MIN_LENGTH', label: 'Мин. длина пароля', group: 'auth', type: 'number', unit: 'симв' },
{ key: 'REGISTRATION_ENABLED', label: 'Регистрация включена', group: 'auth', type: 'boolean' },
{ key: 'MAINTENANCE_MODE', label: 'Режим обслуживания', group: 'auth', type: 'boolean', hint: 'Блокирует вход для обычных пользователей' },
{ key: 'LDAP_ENABLED', label: 'LDAP включён', group: 'ldap', type: 'boolean', hint: 'Показывает кнопку входа через LDAP на странице авторизации' },
{ key: 'LDAP_USE_LDAPS', label: 'Использовать LDAPS', group: 'ldap', type: 'boolean', hint: 'Шифрованное подключение (порт 636)' },
{ key: 'LDAP_HOST', label: 'Хост LDAP', group: 'ldap', type: 'text', hint: 'DC-1.local,DC-2.local или ldaps://DC-1.local:636' },
{ key: 'LDAP_PORT', label: 'Порт LDAP', group: 'ldap', type: 'number', hint: '636 для LDAPS, 389 для LDAP. Не используйте 640.' },
{ key: 'LDAP_DOMAIN', label: 'Домен AD', group: 'ldap', type: 'text', hint: 'mvkug.local — для bind вида user@domain' },
{ key: 'LDAP_SKIP_TLS_VERIFY', label: 'Не проверять TLS', group: 'ldap', type: 'boolean', hint: 'Только для тестовых окружений' },
{ key: 'LDAP_BASE_DN', label: 'Base DN (поиск пользователей)', group: 'ldap', type: 'text', hint: 'OU или CN контейнера с пользователями. Напр. OU=Lugansk,DC=mvkug,DC=local. Если CN не работает — попробуйте OU' },
{ key: 'LDAP_BIND_USERNAME', label: 'Логин сервисной УЗ', group: 'ldap', type: 'text', hint: 'mvkadmin или mvkug\\mvkadmin — учётная запись для подключения к AD' },
{ key: 'LDAP_BIND_DN', label: 'Bind DN (опционально)', group: 'ldap', type: 'text', hint: 'Полный DN сервисной УЗ. Оставьте пустым, если указан логин выше' },
{ key: 'LDAP_BIND_PASSWORD', label: 'Пароль сервисной УЗ', group: 'ldap', type: 'text', hint: 'Секретное значение' },
{ key: 'LDAP_USER_FILTER', label: 'Фильтр пользователя', group: 'ldap', type: 'text', hint: 'Placeholder {username}' },
{ key: 'LDAP_USERNAME_ATTR', label: 'Атрибут логина', group: 'ldap', type: 'text' },
{ key: 'LDAP_EMAIL_ATTR', label: 'Атрибут почты', group: 'ldap', type: 'text' },
{ key: 'LDAP_DISPLAY_NAME_ATTR', label: 'Атрибут имени', group: 'ldap', type: 'text' },
{ key: 'MAX_FAMILY_MEMBERS', label: 'Макс. участников семьи', group: 'limits', type: 'number' },
{ key: 'MAX_ACTIVE_SESSIONS', label: 'Макс. активных сессий', group: 'limits', type: 'number' },
{ key: 'AVATAR_URL_TTL_MINUTES', label: 'TTL ссылки на аватар', group: 'media', type: 'number', unit: 'мин' }
];
export function getSettingMeta(key: string) {
return SYSTEM_SETTING_CATALOG.find((item) => item.key === key);
}
export function sortSettingsByCatalog<T extends { key: string }>(settings: T[]) {
const order = new Map(SYSTEM_SETTING_CATALOG.map((item, index) => [item.key, index]));
return [...settings].sort((a, b) => (order.get(a.key) ?? 999) - (order.get(b.key) ?? 999));
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

4
apps/frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// This file is generated by Next.js and kept for TypeScript compatibility.

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
typedRoutes: false
};
export default nextConfig;

View File

@@ -0,0 +1,42 @@
{
"name": "@lendry/frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.561.0",
"next": "^16.0.10",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.2.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {}
}
};
export default config;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1200" height="600" viewBox="0 0 378 189"><defs><clipPath id="a"><path d="M0 0h200v608h8v284l-8 8H0z"/></clipPath></defs><path fill="#ce1720" d="M0 0h378v189H0z"/><g transform="translate(2) scale(.21)" clip-path="url(#a)" fill="#fff"><g id="c"><path id="b" d="M36 0v14h-9v14H16v16H8v13H-8V24H8V6H-8V0zm26 77v15h-8v12h-8V92h-8V77h-8V57h8V42h8V30h8v12h8v15h8v20zm-17-1h10V58H45zM19 183h8v-18h-8zm54 0h8v-18h-8zM-8 305H6v13h6v16h9v15h12v-15h9v-16h8v-13H38v-15h21v10h13v17h11v19h-8v14h-7v13h-6v14h-9v12h-7v11h-9v14H24v-15h-9v-14H8v-9H-8v-23H8v-20H-8z"/><use xlink:href="#b" transform="matrix(-1 0 0 1 200 0)"/><path d="M96 0v32h8V0h32v14h-8v14h-12v16h-8v13H92V44h-8V28H72V14h-8V0zm-2 274v-11h-6v-13h-7v-14h-8v-14h-8v-10h-9v-14H44v14h-9v10h-7v14h-8v14h-6v13H8v17H-8v-44H8v-20H-8v-33H8v14h10v14h10v-14h10v-14h8v-18h-8v-14H28v-14H18v14H8v14H-8v-41H8v-19H-8V77H8v13h8v16h11v13h9v15h7v12h14v-12h7v-15h9v-13h11V90h8V77h16v13h8v16h11v13h9v15h7v12h14v-12h7v-15h9v-13h11V90h8V77h16v28h-16v19h16v41h-16v-14h-10v-14h-10v14h-10v14h-8v18h8v14h10v14h10v-14h10v-14h16v33h-16v20h16v44h-16v-17h-6v-13h-6v-14h-8v-14h-7v-10h-9v-14h-12v14h-9v10h-8v14h-8v14h-7v13h-6v11zm2-167v27h8v-27zm-4 58v-14H82v-14H72v14H62v14h-8v18h8v14h10v14h10v-14h10v-14h16v14h10v14h10v-14h10v-14h8v-18h-8v-14h-10v-14h-10v14h-10v14zm4 46v27h8v-27z"/></g><use xlink:href="#c" transform="matrix(1 0 0 -1 0 900)"/><path d="M-8 408H8v14h7v8h8v14h7v12h-7v14h-8v8H8v14H-8zm216 0v84h-16v-14h-7v-8h-8v-14h-7v-12h7v-14h8v-8h7v-14zM62 459h8v-18h-8zm76 0v-18h-8v18zm-42-59h8v-18h-8zm0 100v18h8v-18zm-50-75h14v-11h10v-10h5v-10h6v-14h8v-14h4v-13h14v13h4v14h8v14h6v10h5v10h10v11h14v50h-14v11h-10v10h-5v10h-6v14h-8v14h-4v13H93v-13h-4v-14h-8v-14h-6v-10h-5v-10H60v-11H46zm50 9v-15h-8v-10h-8v25h8v9h5v14h-5v9h-8v25h8v-10h8v-15h8v15h8v10h8v-25h-8v-9h-5v-14h5v-9h8v-25h-8v10h-8v15z"/></g><path fill="#007c30" d="M44 126h334v63H44z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6" width="900" height="600"><path fill="#fff" d="M0 0h9v3H0z"/><path fill="#d52b1e" d="M0 3h9v3H0z"/><path fill="#0039a6" d="M0 2h9v2H0z"/></svg>

After

Width:  |  Height:  |  Size: 200 B

View File

@@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": ".",
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}