first commit
This commit is contained in:
42
apps/frontend/Dockerfile
Normal file
42
apps/frontend/Dockerfile
Normal 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"]
|
||||
23
apps/frontend/app/admin/layout.tsx
Normal file
23
apps/frontend/app/admin/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, isPinLocked, hasStoredSession } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!user && !isPinLocked && !hasStoredSession) {
|
||||
router.replace('/auth/login');
|
||||
return;
|
||||
}
|
||||
if (user && !user.canAccessAdmin) {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [hasStoredSession, isLoading, isPinLocked, router, user]);
|
||||
|
||||
return children;
|
||||
}
|
||||
262
apps/frontend/app/admin/oauth/page.tsx
Normal file
262
apps/frontend/app/admin/oauth/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Copy, KeyRound, Loader2, LockKeyhole, Plus, RefreshCw } from 'lucide-react';
|
||||
import { AdminShell } from '@/components/id/admin-shell';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api';
|
||||
|
||||
export default function AdminOAuthPage() {
|
||||
const { token } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [clients, setClients] = useState<OAuthClient[]>([]);
|
||||
const [scopes, setScopes] = useState<OAuthScope[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null);
|
||||
const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [clientsResponse, scopesResponse] = await Promise.all([
|
||||
apiFetch<{ clients: OAuthClient[] }>('/admin/rbac/oauth-clients', {}, token),
|
||||
apiFetch<{ scopes: OAuthScope[] }>('/admin/rbac/oauth-scopes', {}, token)
|
||||
]);
|
||||
setClients(clientsResponse.clients ?? []);
|
||||
setScopes(scopesResponse.scopes ?? []);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить OAuth-приложения');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!token) return;
|
||||
const redirectUris = form.redirectUris
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (!form.name.trim() || !redirectUris.length || !form.selectedScopes.length) {
|
||||
showToast('Заполните название, redirect URI и scopes');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await apiFetch<OAuthClient>('/admin/rbac/oauth-clients', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
redirectUris,
|
||||
scopes: form.selectedScopes,
|
||||
type: form.type
|
||||
})
|
||||
}, token);
|
||||
showToast('OAuth-приложение создано');
|
||||
setDialogOpen(false);
|
||||
setForm({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] });
|
||||
if (created.clientSecret) {
|
||||
setSecretDialog({ clientId: created.clientId, clientSecret: created.clientSecret });
|
||||
}
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось создать приложение');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotateSecret(clientId: string) {
|
||||
if (!token) return;
|
||||
try {
|
||||
const response = await apiFetch<{ clientId: string; clientSecret: string }>(`/admin/rbac/oauth-clients/${clientId}/rotate-secret`, { method: 'POST' }, token);
|
||||
setSecretDialog(response);
|
||||
showToast('Новый client secret сгенерирован');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось перевыпустить secret');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(client: OAuthClient) {
|
||||
if (!token) return;
|
||||
try {
|
||||
await apiFetch(`/admin/rbac/oauth-clients/${client.clientId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !client.isActive })
|
||||
}, token);
|
||||
showToast(client.isActive ? 'Приложение отключено' : 'Приложение включено');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось обновить приложение');
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(value: string, label: string) {
|
||||
void navigator.clipboard.writeText(value);
|
||||
showToast(`${label} скопирован`);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell active="/admin/oauth">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-medium">OAuth-приложения</h2>
|
||||
<p className="text-sm text-[#667085]">Создание клиентов как на oauth.yandex.ru: client_id, secret, redirect URI и scopes</p>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Новое приложение
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{clients.map((client) => (
|
||||
<Card key={client.id} className={client.isActive ? '' : 'opacity-70'}>
|
||||
<CardHeader>
|
||||
<LockKeyhole className="h-6 w-6" />
|
||||
<CardTitle>{client.name}</CardTitle>
|
||||
<CardDescription>{client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="font-medium">Client ID</span>
|
||||
<Button variant="ghost" size="icon" aria-label="Скопировать client id" onClick={() => copyText(client.clientId, 'Client ID')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<code className="break-all text-xs">{client.clientId}</code>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||
<div className="mb-2 font-medium">Redirect URI</div>
|
||||
{client.redirectUris.map((uri) => (
|
||||
<div key={uri} className="break-all text-xs text-[#667085]">
|
||||
{uri}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Scopes
|
||||
</div>
|
||||
<p>{client.scopes.join(', ')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{client.type !== 'PUBLIC' ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => void handleRotateSecret(client.clientId)}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Новый secret
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="secondary" size="sm" onClick={() => void handleToggleActive(client)}>
|
||||
{client.isActive ? 'Отключить' : 'Включить'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{!clients.length ? <p className="text-[#667085]">OAuth-приложения ещё не созданы</p> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новое OAuth-приложение</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Название</label>
|
||||
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Lendry Docs" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Тип клиента</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))}
|
||||
className="h-11 w-full rounded-xl border border-[#eceef4] bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="CONFIDENTIAL">Confidential (с client secret)</option>
|
||||
<option value="PUBLIC">Public (без secret)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Redirect URI (по одному на строку)</label>
|
||||
<textarea
|
||||
value={form.redirectUris}
|
||||
onChange={(event) => setForm((current) => ({ ...current, redirectUris: event.target.value }))}
|
||||
placeholder="https://app.example.com/oauth/callback"
|
||||
className="min-h-[96px] w-full rounded-xl border border-[#eceef4] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Scopes</label>
|
||||
<div className="space-y-2">
|
||||
{scopes.map((scope) => (
|
||||
<label key={scope.id} className="flex items-center gap-2 rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.selectedScopes.includes(scope.slug)}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
selectedScopes: event.target.checked
|
||||
? [...current.selectedScopes, scope.slug]
|
||||
: current.selectedScopes.filter((item) => item !== scope.slug)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">{scope.name}</span>
|
||||
<span className="text-[#667085]">({scope.slug})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" disabled={creating} onClick={() => void handleCreate()}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Создать приложение'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(secretDialog)} onOpenChange={(open) => !open && setSecretDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Client Secret</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-[#667085]">Сохраните secret сейчас — он больше не будет показан полностью.</p>
|
||||
<div className="rounded-2xl bg-[#f4f5f8] p-4">
|
||||
<p className="mb-1 text-xs text-[#667085]">Client ID</p>
|
||||
<code className="break-all text-sm">{secretDialog?.clientId}</code>
|
||||
<p className="mb-1 mt-4 text-xs text-[#667085]">Client Secret</p>
|
||||
<code className="break-all text-sm">{secretDialog?.clientSecret}</code>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => secretDialog && copyText(secretDialog.clientSecret, 'Client Secret')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
Скопировать secret
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
5
apps/frontend/app/admin/page.tsx
Normal file
5
apps/frontend/app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminIndexPage() {
|
||||
redirect('/admin/users');
|
||||
}
|
||||
143
apps/frontend/app/admin/rbac/page.tsx
Normal file
143
apps/frontend/app/admin/rbac/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Plus, ShieldCheck } from 'lucide-react';
|
||||
import { AdminShell } from '@/components/id/admin-shell';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AdminPermission, AdminRole, apiFetch } from '@/lib/api';
|
||||
|
||||
export default function AdminRbacPage() {
|
||||
const { token, user } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [roles, setRoles] = useState<AdminRole[]>([]);
|
||||
const [permissions, setPermissions] = useState<AdminPermission[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState({ slug: '', name: '', description: '', permissionSlugs: [] as string[] });
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !user?.canManageRoles) return;
|
||||
Promise.all([
|
||||
apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token),
|
||||
apiFetch<{ permissions: AdminPermission[] }>('/admin/rbac/permissions', {}, token)
|
||||
])
|
||||
.then(([rolesResponse, permissionsResponse]) => {
|
||||
setRoles(rolesResponse.roles ?? []);
|
||||
setPermissions(permissionsResponse.permissions ?? []);
|
||||
})
|
||||
.catch((error) => showToast(error instanceof Error ? error.message : 'Не удалось загрузить роли'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast, token, user?.canManageRoles]);
|
||||
|
||||
async function handleCreateRole() {
|
||||
if (!token) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await apiFetch('/admin/rbac/roles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form)
|
||||
}, token);
|
||||
showToast('Роль создана');
|
||||
setDialogOpen(false);
|
||||
setForm({ slug: '', name: '', description: '', permissionSlugs: [] });
|
||||
const response = await apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token);
|
||||
setRoles(response.roles ?? []);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось создать роль');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user?.canManageRoles) {
|
||||
return (
|
||||
<AdminShell active="/admin/rbac">
|
||||
<p className="text-[#667085]">Управление ролями доступно только супер-администратору.</p>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell active="/admin/rbac">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-medium">Роли и права</h2>
|
||||
<p className="text-sm text-[#667085]">Только супер-администратор может создавать роли и назначать их пользователям</p>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Создать роль
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{roles.map((role) => (
|
||||
<Card key={role.id} className="bg-[#f8f9fb] shadow-none">
|
||||
<CardHeader>
|
||||
<ShieldCheck className="h-6 w-6" />
|
||||
<CardTitle>{role.name}</CardTitle>
|
||||
<CardDescription>{role.permissions.length} прав · {role.slug}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{role.permissions.map((permission) => (
|
||||
<div key={permission.id} className="rounded-xl bg-white px-3 py-2 text-sm">
|
||||
<div className="font-medium">{permission.name}</div>
|
||||
<div className="text-xs text-[#667085]">{permission.slug}</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новая роль</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="slug, например support" />
|
||||
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Название роли" />
|
||||
<Input value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Описание" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Права</p>
|
||||
{permissions.map((permission) => (
|
||||
<label key={permission.id} className="flex items-center gap-2 rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.permissionSlugs.includes(permission.slug)}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
permissionSlugs: event.target.checked
|
||||
? [...current.permissionSlugs, permission.slug]
|
||||
: current.permissionSlugs.filter((item) => item !== permission.slug)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>{permission.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button className="w-full" disabled={creating} onClick={() => void handleCreateRole()}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Создать роль'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
219
apps/frontend/app/admin/settings/page.tsx
Normal file
219
apps/frontend/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Chrome, Loader2, Save, Settings2, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { AdminShell } from '@/components/id/admin-shell';
|
||||
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
|
||||
import { getSettingMeta, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog';
|
||||
|
||||
function parseBoolean(value: string) {
|
||||
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { token, user } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { refreshPublicSettings } = usePublicSettings();
|
||||
const [settings, setSettings] = useState<SystemSetting[]>([]);
|
||||
const [providers, setProviders] = useState<SocialProvider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !user?.canManageSettings) return;
|
||||
Promise.all([
|
||||
apiFetch<{ settings: SystemSetting[] }>('/admin/settings', {}, token),
|
||||
apiFetch<{ providers: SocialProvider[] }>('/admin/settings/oauth/providers', {}, token)
|
||||
])
|
||||
.then(([settingsResponse, providersResponse]) => {
|
||||
setSettings(sortSettingsByCatalog(settingsResponse.settings ?? []));
|
||||
setProviders(providersResponse.providers ?? []);
|
||||
})
|
||||
.catch((error) => showToast(error instanceof Error ? error.message : 'Не удалось загрузить настройки'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast, token, user?.canManageSettings]);
|
||||
|
||||
const groupedSettings = useMemo(() => {
|
||||
const groups = new Map<string, SystemSetting[]>();
|
||||
for (const setting of settings) {
|
||||
const meta = getSettingMeta(setting.key);
|
||||
const group = meta?.group ?? 'other';
|
||||
const list = groups.get(group) ?? [];
|
||||
list.push(setting);
|
||||
groups.set(group, list);
|
||||
}
|
||||
return groups;
|
||||
}, [settings]);
|
||||
|
||||
async function saveSetting(setting: SystemSetting) {
|
||||
if (!token) return;
|
||||
setSavingKey(setting.key);
|
||||
try {
|
||||
const updated = await apiFetch<SystemSetting>(
|
||||
'/admin/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(buildSystemSettingPayload(setting))
|
||||
},
|
||||
token
|
||||
);
|
||||
setSettings((current) => sortSettingsByCatalog(current.map((item) => (item.key === updated.key ? updated : item))));
|
||||
if (updated.key === 'PROJECT_NAME' || updated.key === 'PROJECT_TAGLINE' || updated.key === 'LDAP_ENABLED' || updated.key === 'LDAP_USE_LDAPS') {
|
||||
await refreshPublicSettings();
|
||||
}
|
||||
showToast('Настройка сохранена');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройку');
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSettingValue(key: string, value: string) {
|
||||
setSettings((current) => current.map((item) => (item.key === key ? { ...item, value } : item)));
|
||||
}
|
||||
|
||||
function toProviderPayload(provider: SocialProvider) {
|
||||
return {
|
||||
providerName: provider.providerName,
|
||||
clientId: provider.clientId,
|
||||
isEnabled: provider.isEnabled
|
||||
};
|
||||
}
|
||||
|
||||
async function toggleProvider(provider: SocialProvider) {
|
||||
if (!token) return;
|
||||
try {
|
||||
const updated = await apiFetch<SocialProvider>('/admin/settings/oauth/providers', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
|
||||
}, token);
|
||||
setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item)));
|
||||
showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось обновить провайдера');
|
||||
}
|
||||
}
|
||||
|
||||
if (!user?.canManageSettings) {
|
||||
return (
|
||||
<AdminShell active="/admin/settings">
|
||||
<p className="text-[#667085]">У вас нет прав на изменение глобальных настроек.</p>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell active="/admin/settings">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-medium">Глобальные настройки</h2>
|
||||
<p className="mt-2 text-[#667085]">Параметры хранятся в SystemSetting и применяются сервисами без релиза frontend.</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => (
|
||||
<section key={groupKey} className="mb-10">
|
||||
<h3 className="mb-4 text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
|
||||
<div className="space-y-3">
|
||||
{groupSettings.map((setting) => {
|
||||
const meta = getSettingMeta(setting.key);
|
||||
const label = meta?.label ?? setting.key;
|
||||
const type = meta?.type ?? 'text';
|
||||
|
||||
return (
|
||||
<div key={setting.key} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Settings2 className="h-4 w-4 shrink-0" />
|
||||
{label}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{type === 'boolean' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
updateSettingValue(setting.key, parseBoolean(setting.value) ? 'false' : 'true');
|
||||
}}
|
||||
>
|
||||
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
|
||||
value={setting.value}
|
||||
className={setting.isSecret ? 'w-[220px] bg-white' : 'w-[180px] bg-white'}
|
||||
onChange={(event) => updateSettingValue(setting.key, event.target.value)}
|
||||
/>
|
||||
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button disabled={savingKey === setting.key} onClick={() => void saveSetting(setting)}>
|
||||
{savingKey === setting.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="mt-10">
|
||||
<h3 className="text-xl font-medium">OAuth Social Providers</h3>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{(providers.length ? providers : [{ id: 'google', providerName: 'google', clientId: '', isEnabled: false }, { id: 'yandex', providerName: 'yandex', clientId: '', isEnabled: false }]).map((provider) => (
|
||||
<div key={provider.providerName} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
{provider.providerName === 'google' ? <Chrome className="h-7 w-7" /> : <div className="flex h-8 w-8 items-center justify-center rounded-full bg-red-500 font-bold text-white">Я</div>}
|
||||
<button type="button" aria-label="Переключить провайдера" onClick={() => void toggleProvider(provider)}>
|
||||
{provider.isEnabled ? <ToggleRight className="h-8 w-8 text-green-600" /> : <ToggleLeft className="h-8 w-8 text-[#a8adbc]" />}
|
||||
</button>
|
||||
</div>
|
||||
<h4 className="mt-5 font-semibold capitalize">{provider.providerName}</h4>
|
||||
<p className="mt-1 text-sm text-[#667085]">{provider.isEnabled ? 'Провайдер включен глобально.' : 'Провайдер отключен.'}</p>
|
||||
<Input
|
||||
className="mt-4 bg-white"
|
||||
value={provider.clientId}
|
||||
placeholder="Client ID"
|
||||
onChange={(event) => setProviders((current) => current.map((item) => (item.providerName === provider.providerName ? { ...item, clientId: event.target.value } : item)))}
|
||||
/>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
void apiFetch('/admin/settings/oauth/providers', { method: 'PUT', body: JSON.stringify(toProviderPayload(provider)) }, token)
|
||||
.then(() => showToast('Провайдер сохранён'))
|
||||
.catch((error) => showToast(error instanceof Error ? error.message : 'Ошибка сохранения'))
|
||||
}
|
||||
>
|
||||
Сохранить провайдера
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
323
apps/frontend/app/admin/users/page.tsx
Normal file
323
apps/frontend/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCog } from 'lucide-react';
|
||||
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
|
||||
import { AdminShell } from '@/components/id/admin-shell';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { AdminRole, AdminUser, apiFetch } from '@/lib/api';
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
ACTIVE: 'Активен',
|
||||
SUSPENDED: 'Заблокирован',
|
||||
DELETED: 'Удалён'
|
||||
};
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
admin: 'Администратор',
|
||||
moderator: 'Модератор',
|
||||
manager: 'Менеджер',
|
||||
'super-admin': 'Супер-админ'
|
||||
};
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { token, user: currentUser } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [roles, setRoles] = useState<AdminRole[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [dialog, setDialog] = useState<'password' | 'roles' | null>(null);
|
||||
const [documentsUser, setDocumentsUser] = useState<AdminUser | null>(null);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<{ users: AdminUser[] }>(`/admin/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, {}, token);
|
||||
setUsers(response.users ?? []);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить пользователей');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, showToast, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !currentUser?.canManageRoles) return;
|
||||
apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token)
|
||||
.then((response) => setRoles(response.roles ?? []))
|
||||
.catch(() => undefined);
|
||||
}, [currentUser?.canManageRoles, token]);
|
||||
|
||||
const assignableRoles = useMemo(() => roles.filter((role) => role.slug !== 'super-admin'), [roles]);
|
||||
|
||||
async function handleSuspend(user: AdminUser) {
|
||||
if (!token) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${user.id}/suspend`, { method: 'POST' }, token);
|
||||
showToast('Пользователь заблокирован');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось заблокировать пользователя');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (!token || !selectedUser || password.length < 8) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${selectedUser.id}/reset-password`, { method: 'POST', body: JSON.stringify({ newPassword: password }) }, token);
|
||||
showToast('Пароль обновлён');
|
||||
setDialog(null);
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось сбросить пароль');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSuperAdmin(user: AdminUser) {
|
||||
if (!token || !currentUser?.canManageRoles) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${user.id}/super-admin`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isSuperAdmin: !user.isSuperAdmin })
|
||||
}, token);
|
||||
showToast(user.isSuperAdmin ? 'Права супер-админа сняты' : 'Пользователь назначен супер-админом');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось изменить права');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignRole(roleSlug: string) {
|
||||
if (!token || !selectedUser) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<{ roles: string[] }>(`/admin/rbac/users/${selectedUser.id}/roles`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ roleSlug })
|
||||
}, token);
|
||||
setSelectedUser({ ...selectedUser, roles: response.roles ?? [] });
|
||||
showToast('Роль назначена');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось назначить роль');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveRole(roleSlug: string) {
|
||||
if (!token || !selectedUser) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/rbac/users/${selectedUser.id}/roles/${roleSlug}`, { method: 'DELETE' }, token);
|
||||
setSelectedUser({ ...selectedUser, roles: (selectedUser.roles ?? []).filter((role) => role !== roleSlug) });
|
||||
showToast('Роль снята');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось снять роль');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRoles(user: AdminUser) {
|
||||
const userRoles = user.roles ?? [];
|
||||
const labels = user.isSuperAdmin ? ['Супер-админ', ...userRoles.map((role) => roleLabels[role] ?? role)] : userRoles.map((role) => roleLabels[role] ?? role);
|
||||
if (!labels.length) return 'Пользователь';
|
||||
return labels.join(', ');
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell active="/admin/users">
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-medium">Пользователи</h2>
|
||||
<p className="text-sm text-[#667085]">Поиск, блокировка, сброс пароля и управление доступом</p>
|
||||
</div>
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Поиск по имени, почте или телефону" className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#eceef4] bg-white">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Пользователь</TableHead>
|
||||
<TableHead>Телефон</TableHead>
|
||||
<TableHead>Роли</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-sm text-[#667085]">{user.email ?? user.username ?? '—'}</div>
|
||||
</TableCell>
|
||||
<TableCell>{user.phone ?? '—'}</TableCell>
|
||||
<TableCell>{renderRoles(user)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
{currentUser?.canManageUsers ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Сбросить пароль"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setPassword('');
|
||||
setDialog('password');
|
||||
}}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="icon" aria-label="Заблокировать" disabled={actionLoading || user.status === 'SUSPENDED'} onClick={() => void handleSuspend(user)}>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{currentUser?.canViewUserDocuments ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Документы пользователя"
|
||||
disabled={actionLoading}
|
||||
onClick={() => setDocumentsUser(user)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{currentUser?.canManageRoles ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Управление ролями"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setDialog('roles');
|
||||
}}
|
||||
>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={user.isSuperAdmin ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
aria-label="Супер-админ"
|
||||
disabled={actionLoading || user.id === currentUser?.id}
|
||||
onClick={() => void handleToggleSuperAdmin(user)}
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialog === 'password'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Сброс пароля</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="mb-4 text-sm text-[#667085]">Новый пароль для {selectedUser?.displayName}</p>
|
||||
<Input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Минимум 8 символов" />
|
||||
<Button className="mt-4 w-full" disabled={actionLoading || password.length < 8} onClick={() => void handleResetPassword()}>
|
||||
{actionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Сохранить пароль'}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={dialog === 'roles'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Роли и доступ</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="mb-4 text-sm text-[#667085]">{selectedUser?.displayName}</p>
|
||||
<div className="space-y-2">
|
||||
{(selectedUser?.roles ?? []).map((role) => (
|
||||
<div key={role} className="flex items-center justify-between rounded-xl bg-[#f4f5f8] px-3 py-2">
|
||||
<span>{roleLabels[role] ?? role}</span>
|
||||
<Button variant="ghost" size="sm" disabled={actionLoading} onClick={() => void handleRemoveRole(role)}>
|
||||
Снять
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!(selectedUser?.roles ?? []).length ? <p className="text-sm text-[#667085]">Роли не назначены</p> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium">Назначить роль</p>
|
||||
{assignableRoles.map((role) => (
|
||||
<Button
|
||||
key={role.id}
|
||||
variant="secondary"
|
||||
className="w-full justify-start"
|
||||
disabled={actionLoading || (selectedUser?.roles ?? []).includes(role.slug)}
|
||||
onClick={() => void handleAssignRole(role.slug)}
|
||||
>
|
||||
<ShieldPlus className="mr-2 h-4 w-4" />
|
||||
{role.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{documentsUser ? (
|
||||
<UserDocumentsDialog
|
||||
open={Boolean(documentsUser)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDocumentsUser(null);
|
||||
}}
|
||||
targetUserId={documentsUser.id}
|
||||
targetUserName={documentsUser.displayName}
|
||||
token={token}
|
||||
/>
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
365
apps/frontend/app/auth/login/page.tsx
Normal file
365
apps/frontend/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronLeft, KeyRound, Mail, Network, Phone, ShieldQuestion, UserRound } from 'lucide-react';
|
||||
import { BrandLogo, ProjectTagline } from '@/components/id/brand-logo';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { OtpInput } from '@/components/ui/otp-input';
|
||||
import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { LoginMethod } from '@/lib/api';
|
||||
|
||||
type Step = 'identify' | 'otp' | 'password' | 'pin' | 'ldap';
|
||||
|
||||
const channelIcon: Record<string, typeof Mail> = {
|
||||
email: Mail,
|
||||
backupEmail: Mail,
|
||||
phone: Phone,
|
||||
backupPhone: Phone,
|
||||
password: KeyRound
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, completePin } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { ldapEnabled, ldapUseLdaps } = usePublicSettings();
|
||||
|
||||
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [country, setCountry] = useState(phoneCountries[0]);
|
||||
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [maskedTarget, setMaskedTarget] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [ldapUsername, setLdapUsername] = useState('');
|
||||
const [ldapPassword, setLdapPassword] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||
const [methods, setMethods] = useState<LoginMethod[]>([]);
|
||||
const [showMethods, setShowMethods] = useState(false);
|
||||
const [step, setStep] = useState<Step>('identify');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
function getIdentifier() {
|
||||
return authTab === 'email' ? email.trim() : toE164(country, phoneNumber);
|
||||
}
|
||||
|
||||
function finishAuth(pinVerified: boolean, sessionId: string) {
|
||||
if (!pinVerified) {
|
||||
setPendingSessionId(sessionId);
|
||||
setStep('pin');
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIdentify(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const identifier = getIdentifier();
|
||||
setRecipient(identifier);
|
||||
const result = await identifyLogin(identifier);
|
||||
setMethods(result.methods ?? []);
|
||||
const masked = await sendLoginOtp(identifier);
|
||||
setMaskedTarget(masked);
|
||||
setOtp('');
|
||||
setStep('otp');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось продолжить вход');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyOtp(code: string) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await verifyLoginOtp(recipient, code);
|
||||
if (response.auth) {
|
||||
finishAuth(response.auth.pinVerified, response.auth.sessionId);
|
||||
} else {
|
||||
showToast('Не удалось завершить вход');
|
||||
}
|
||||
} catch (error) {
|
||||
setOtp('');
|
||||
showToast(error instanceof Error ? error.message : 'Неверный код');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const auth = await loginWithPassword(recipient, password);
|
||||
finishAuth(auth.pinVerified, auth.sessionId);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Неверный пароль');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseMethod(method: LoginMethod) {
|
||||
setShowMethods(false);
|
||||
if (method.kind === 'password') {
|
||||
setPassword('');
|
||||
setStep('password');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const masked = await sendLoginOtp(recipient, method.channel);
|
||||
setMaskedTarget(masked);
|
||||
setOtp('');
|
||||
setStep('otp');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLdapSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const auth = await loginWithLdap(ldapUsername.trim(), ldapPassword);
|
||||
finishAuth(auth.pinVerified, auth.sessionId);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось выполнить LDAP-вход');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePinSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!pendingSessionId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await completePin(pendingSessionId, pin);
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось проверить PIN-код');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetToIdentify() {
|
||||
setStep('identify');
|
||||
setShowMethods(false);
|
||||
setOtp('');
|
||||
setPassword('');
|
||||
setLdapUsername('');
|
||||
setLdapPassword('');
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#596075_0%,#2b2534_45%,#121017_100%)] px-5">
|
||||
<section className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-9 pb-8 pt-10 text-white shadow-2xl">
|
||||
<div className="mb-8 flex flex-col items-center text-center">
|
||||
<BrandLogo size="lg" variant="light" />
|
||||
<h1 className="mt-8 text-xl font-bold">Войдите с ID</h1>
|
||||
</div>
|
||||
|
||||
{step !== 'identify' ? (
|
||||
<button type="button" onClick={resetToIdentify} className="mb-4 flex items-center gap-1 text-sm text-[#b9bdc9] hover:text-white">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Изменить аккаунт
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{step === 'identify' ? (
|
||||
<form onSubmit={handleIdentify}>
|
||||
<Tabs value={authTab} onValueChange={(value) => setAuthTab(value as 'email' | 'phone')} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="email">Почта</TabsTrigger>
|
||||
<TabsTrigger value="phone">Телефон</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="email">
|
||||
<Input
|
||||
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required={authTab === 'email'}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="phone">
|
||||
<PhoneInput country={country} value={phoneNumber} onCountryChange={setCountry} onValueChange={setPhoneNumber} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Отправляем код...' : 'Далее'}
|
||||
</Button>
|
||||
{ldapEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]"
|
||||
onClick={() => {
|
||||
setLdapUsername('');
|
||||
setLdapPassword('');
|
||||
setStep('ldap');
|
||||
}}
|
||||
>
|
||||
<Network className="h-5 w-5" />
|
||||
{ldapUseLdaps ? 'Войти через LDAPS' : 'Войти через LDAP'}
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === 'ldap' ? (
|
||||
<form onSubmit={handleLdapSubmit}>
|
||||
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
|
||||
<Network className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-sm text-[#b9bdc9]">Корпоративный вход</p>
|
||||
<p className="truncate text-sm font-semibold">{ldapUseLdaps ? 'LDAPS' : 'LDAP'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="Логин LDAP"
|
||||
value={ldapUsername}
|
||||
onChange={(event) => setLdapUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
className="mt-3 h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="Пароль LDAP"
|
||||
type="password"
|
||||
value={ldapPassword}
|
||||
onChange={(event) => setLdapPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Проверяем...' : ldapUseLdaps ? 'Войти через LDAPS' : 'Войти через LDAP'}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === 'otp' ? (
|
||||
<div>
|
||||
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
|
||||
<UserRound className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-sm text-[#b9bdc9]">Код отправлен на</p>
|
||||
<p className="truncate text-sm font-semibold">{maskedTarget || recipient}</p>
|
||||
</div>
|
||||
</div>
|
||||
<OtpInput value={otp} onChange={setOtp} onComplete={verifyOtp} disabled={isSubmitting} />
|
||||
{isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Проверяем код...</p> : null}
|
||||
|
||||
{methods.length ? (
|
||||
<div className="mt-6">
|
||||
<Button type="button" variant="outline" size="lg" className="w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setShowMethods((prev) => !prev)}>
|
||||
<ShieldQuestion className="h-5 w-5" />
|
||||
Другой способ входа
|
||||
</Button>
|
||||
{showMethods ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{methods.map((method) => {
|
||||
const Icon = channelIcon[method.channel] ?? KeyRound;
|
||||
return (
|
||||
<button
|
||||
key={`${method.kind}-${method.channel}`}
|
||||
type="button"
|
||||
onClick={() => chooseMethod(method)}
|
||||
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f]"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-[#b9bdc9]" />
|
||||
<span className="text-sm">
|
||||
{method.kind === 'password' ? 'Войти с паролем' : `Код на ${method.masked}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'password' ? (
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
|
||||
<UserRound className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-sm text-[#b9bdc9]">Вход в аккаунт</p>
|
||||
<p className="truncate text-sm font-semibold">{recipient}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="Пароль"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Входим...' : 'Войти'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setStep('otp')}>
|
||||
Войти по коду
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === 'pin' && pendingSessionId ? (
|
||||
<form onSubmit={handlePinSubmit} className="space-y-3">
|
||||
<Input
|
||||
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="PIN"
|
||||
value={pin}
|
||||
onChange={(event) => setPin(event.target.value)}
|
||||
required
|
||||
minLength={4}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Button variant="white" size="lg" className="w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Проверяем...' : 'Подтвердить PIN'}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{step === 'identify' ? (
|
||||
<div className="mt-3">
|
||||
<Button asChild variant="outline" size="lg" className="w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]">
|
||||
<Link href="/auth/register">Создать ID</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ProjectTagline className="mt-9 text-center text-sm font-semibold" />
|
||||
<p className="mt-2 text-center text-sm font-bold">Узнать больше</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
82
apps/frontend/app/auth/register/page.tsx
Normal file
82
apps/frontend/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { BrandLogo } from '@/components/id/brand-logo';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { sendLoginOtp } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [country, setCountry] = useState(phoneCountries[0]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const recipient = authTab === 'email' ? email.trim() : toE164(country, phoneNumber);
|
||||
await sendLoginOtp(recipient);
|
||||
showToast('Код отправлен. Подтвердите его на экране входа.');
|
||||
router.push('/auth/login');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#5d6578_0%,#2c2736_48%,#111016_100%)] px-5">
|
||||
<section className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-9 py-10 text-white shadow-2xl">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<BrandLogo size="lg" variant="light" />
|
||||
<h1 className="mt-8 text-xl font-bold">Создайте ID</h1>
|
||||
<p className="mt-2 text-sm text-[#b9bdc9]">Введите почту или телефон. Пароль не нужен.</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-3" onSubmit={handleSubmit}>
|
||||
<Tabs value={authTab} onValueChange={(value) => setAuthTab(value as 'email' | 'phone')} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="email">Почта</TabsTrigger>
|
||||
<TabsTrigger value="phone">Телефон</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="email">
|
||||
<Input
|
||||
className="h-[56px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required={authTab === 'email'}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="phone">
|
||||
<PhoneInput country={country} value={phoneNumber} onCountryChange={setCountry} onValueChange={setPhoneNumber} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Button variant="white" size="lg" className="w-full rounded-[18px]" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Отправляем...' : 'Получить код'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-5 flex items-start gap-3 rounded-[22px] bg-[#2a2c36] p-4 text-sm">
|
||||
<ShieldCheck className="mt-0.5 h-5 w-5 text-[#8ec5ff]" />
|
||||
<p>Первый зарегистрированный пользователь автоматически станет суперадминистратором.</p>
|
||||
</div>
|
||||
<Button asChild variant="ghost" className="mt-5 w-full text-white hover:bg-[#2a2c36]">
|
||||
<Link href="/auth/login">У меня уже есть ID</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
291
apps/frontend/app/data/page.tsx
Normal file
291
apps/frontend/app/data/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Car, ChevronRight, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { ActionTile } from '@/components/id/action-tile';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { AvatarDisplay, AvatarUpload } from '@/components/id/avatar-upload';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
import {
|
||||
getDocumentType,
|
||||
indexDocumentsByType,
|
||||
type DocumentTypeCode
|
||||
} from '@/lib/document-catalog';
|
||||
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument } from '@/lib/api';
|
||||
|
||||
function Row({
|
||||
icon: Icon,
|
||||
title,
|
||||
text,
|
||||
onClick,
|
||||
destructive
|
||||
}: {
|
||||
icon: typeof Mail;
|
||||
title: string;
|
||||
text?: string;
|
||||
onClick?: () => void;
|
||||
destructive?: boolean;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl ${destructive ? 'bg-red-50' : 'bg-[#f4f5f8]'}`}>
|
||||
<Icon className={`h-5 w-5 ${destructive ? 'text-red-500' : ''}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`font-medium ${destructive ? 'text-red-600' : ''}`}>{title}</div>
|
||||
{text ? <div className="truncate text-sm text-[#667085]">{text}</div> : null}
|
||||
</div>
|
||||
{onClick ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!onClick) {
|
||||
return <div className="flex items-center gap-4 border-b border-[#eceef4] py-4">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-4 border-b border-[#eceef4] py-4 text-left transition hover:bg-[#fafbfd]"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataPage() {
|
||||
const router = useRouter();
|
||||
const { user, token, refreshProfile, logout } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [backupEmail, setBackupEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [backupPhone, setBackupPhone] = useState('');
|
||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
try {
|
||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
|
||||
setDocuments(response.documents ?? []);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
|
||||
if (message) showToast(message);
|
||||
}
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setDisplayName(user.displayName);
|
||||
setEmail(user.email ?? '');
|
||||
setBackupEmail(user.backupEmail ?? '');
|
||||
setPhone(user.phone ?? '');
|
||||
setBackupPhone(user.backupPhone ?? '');
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && user && !isPinLocked) void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||||
|
||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||
|
||||
function openDocument(type: DocumentTypeCode) {
|
||||
setActiveDocumentType(type);
|
||||
}
|
||||
|
||||
async function updateProfile() {
|
||||
if (!user || !token) return;
|
||||
setIsSaving(true);
|
||||
const trimmedName = displayName.trim();
|
||||
const [firstName, ...rest] = trimmedName.split(' ');
|
||||
try {
|
||||
await apiFetch(`/profile/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ firstName: firstName || undefined, lastName: rest.join(' ') || undefined })
|
||||
}, token);
|
||||
|
||||
const contacts: Record<string, string> = {};
|
||||
if (email.trim() && email.trim() !== (user.email ?? '')) contacts.email = email.trim();
|
||||
if (phone.trim() && phone.trim() !== (user.phone ?? '')) contacts.phone = phone.trim();
|
||||
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
|
||||
if (backupPhone.trim() && backupPhone.trim() !== (user.backupPhone ?? '')) contacts.backupPhone = backupPhone.trim();
|
||||
|
||||
if (Object.keys(contacts).length > 0) {
|
||||
await apiFetch(`/profile/users/${user.id}/contacts`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(contacts)
|
||||
}, token);
|
||||
}
|
||||
|
||||
await refreshProfile();
|
||||
showToast('Профиль обновлён');
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось обновить профиль');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteProfile() {
|
||||
if (!user || !token) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await softDeleteProfile(user.id, token);
|
||||
setDeleteDialogOpen(false);
|
||||
showToast('Профиль удалён');
|
||||
logout();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось удалить профиль');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/data">
|
||||
<div className="mb-8 flex items-center gap-4 rounded-[24px] border border-[#20212b] p-4">
|
||||
{user ? (
|
||||
<AvatarUpload userId={user.id} displayName={user.displayName} hasAvatar={user.hasAvatar} token={token} onUpdated={refreshProfile} />
|
||||
) : (
|
||||
<AvatarDisplay userId="" displayName="" hasAvatar={false} token={null} className="h-14 w-14" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-semibold">{user?.displayName ?? 'Профиль'}</h1>
|
||||
<p className="text-sm text-[#667085]">Логин: {user?.username ?? user?.email ?? user?.phone ?? 'не указан'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-medium">Личные данные</h2>
|
||||
<p className="mt-1 text-sm text-[#667085]">Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.</p>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<Input placeholder="ФИО" value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||||
<Input placeholder="Дата рождения" />
|
||||
<Input placeholder="Основная почта" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
<Input placeholder="Резервная почта" value={backupEmail} onChange={(event) => setBackupEmail(event.target.value)} />
|
||||
<Input placeholder="Основной телефон" value={phone} onChange={(event) => setPhone(event.target.value)} />
|
||||
<Input placeholder="Резервный телефон" value={backupPhone} onChange={(event) => setBackupPhone(event.target.value)} />
|
||||
</div>
|
||||
<Button className="mt-4" onClick={updateProfile} disabled={isSaving}>
|
||||
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-medium">Документы</h2>
|
||||
<p className="mt-1 text-sm text-[#667085]">ID бережно хранит документы и подставляет их там, где вы разрешили.</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => router.push('/documents')}>Все документы</Button>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-4 gap-3">
|
||||
<ActionTile icon={FileText} label="Паспорт РФ" onClick={() => openDocument('PASSPORT_RF')} />
|
||||
<ActionTile icon={FileText} label="Загран" onClick={() => openDocument('FOREIGN_PASSPORT')} />
|
||||
<ActionTile icon={Car} label="ВУ" onClick={() => openDocument('DRIVER_LICENSE')} />
|
||||
<ActionTile icon={FileText} label="ОМС" onClick={() => openDocument('OMS')} />
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||
{documents.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-[#667085]">Документы пока не добавлены</p>
|
||||
) : (
|
||||
documents.map((document) => {
|
||||
const config = getDocumentType(document.type);
|
||||
return (
|
||||
<Row
|
||||
key={document.id}
|
||||
icon={FileText}
|
||||
title={config?.label ?? document.type}
|
||||
text={document.number}
|
||||
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AddressQuickSection layout="data" isPinLocked={isPinLocked} isReady={isReady} />
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Контакты</h2>
|
||||
<Row icon={Mail} title="Email в ID" text={email || 'Не указан'} />
|
||||
<Row icon={Phone} title="Основной телефон" text={phone || 'Не указан'} />
|
||||
<Row icon={Mail} title="Запасная почта" text={backupEmail || 'Не указана'} />
|
||||
<Row icon={UserRound} title="Добавить внешние аккаунты" text="Помогут быстрее входить и заполнить данные" />
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Управление данными</h2>
|
||||
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40">
|
||||
<Row
|
||||
icon={Trash2}
|
||||
title="Удалить профиль"
|
||||
text="Аккаунт будет деактивирован, вход станет невозможен"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{activeDocumentType && user ? (
|
||||
<DocumentFormDialog
|
||||
open={Boolean(activeDocumentType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveDocumentType(null);
|
||||
}}
|
||||
documentType={activeDocumentType}
|
||||
userId={user.id}
|
||||
token={token}
|
||||
existing={documentsByType.get(activeDocumentType) ?? null}
|
||||
onSaved={loadDocuments}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Удалить профиль?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm leading-relaxed text-[#667085]">
|
||||
Ваш аккаунт будет помечен как удалённый. Вы сразу выйдете из системы и больше не сможете войти с текущими
|
||||
данными. Почта, телефон и логин будут освобождены для новой регистрации. Административные роли будут сняты.
|
||||
Запись в базе сохранится в архивном виде.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button className="flex-1 bg-red-600 hover:bg-red-700" onClick={() => void confirmDeleteProfile()} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Удаляем...
|
||||
</>
|
||||
) : (
|
||||
'Удалить профиль'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
133
apps/frontend/app/documents/page.tsx
Normal file
133
apps/frontend/app/documents/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronRight, Plus } from 'lucide-react';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import {
|
||||
DOCUMENT_CATEGORIES,
|
||||
DOCUMENT_TYPES,
|
||||
getDocumentType,
|
||||
QUICK_DOCUMENT_TYPES,
|
||||
indexDocumentsByType,
|
||||
type DocumentTypeCode
|
||||
} from '@/lib/document-catalog';
|
||||
import { apiFetch, getApiErrorMessage, UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||
const [activeType, setActiveType] = useState<DocumentTypeCode | null>(null);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
try {
|
||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
|
||||
setDocuments(response.documents ?? []);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
|
||||
if (message) showToast(message);
|
||||
}
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !user || isPinLocked) return;
|
||||
void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||||
|
||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||
|
||||
function openCreate(type: DocumentTypeCode) {
|
||||
setActiveType(type);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/documents" wide>
|
||||
<h1 className="text-3xl font-medium tracking-tight">Документы</h1>
|
||||
<p className="mt-1 text-sm text-[#667085]">Храните документы и заполняйте формы — ID подставит данные там, где вы разрешите.</p>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-[28px] bg-[linear-gradient(135deg,#fff4f4,#fff8ef)] p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-lg font-medium">Здесь будут ваши документы</p>
|
||||
<p className="mt-1 text-sm text-[#667085]">Добавьте паспорт, права или полис — данные сохранятся в зашифрованном виде.</p>
|
||||
</div>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-white text-3xl shadow-sm">🛂</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
{QUICK_DOCUMENT_TYPES.map((typeCode) => {
|
||||
const config = getDocumentType(typeCode);
|
||||
if (!config) return null;
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={typeCode}
|
||||
type="button"
|
||||
onClick={() => openCreate(typeCode)}
|
||||
className="flex min-h-[110px] flex-col items-center justify-center gap-2 rounded-[22px] bg-[#f4f5f8] p-3 text-center transition hover:bg-[#eceef4]"
|
||||
>
|
||||
<div className={cn('flex h-12 w-12 items-center justify-center rounded-2xl', config.accent)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-[12px] font-medium leading-tight">Добавить {config.shortLabel.toLowerCase()}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{DOCUMENT_CATEGORIES.map((category) => (
|
||||
<section key={category.id} className="mt-8">
|
||||
<h2 className="mb-3 text-xl font-medium">{category.title}</h2>
|
||||
<div className="overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||
{DOCUMENT_TYPES.filter((item) => item.category === category.id).map((item) => {
|
||||
const Icon = item.icon;
|
||||
const existing = documentsByType.get(item.type);
|
||||
return (
|
||||
<button
|
||||
key={item.type}
|
||||
type="button"
|
||||
onClick={() => openCreate(item.type)}
|
||||
className="flex w-full items-center gap-4 border-b border-white/70 px-4 py-4 text-left last:border-b-0 hover:bg-white/60"
|
||||
>
|
||||
<div className={cn('flex h-11 w-11 items-center justify-center rounded-2xl', item.accent)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{item.label}</div>
|
||||
{existing ? <div className="truncate text-sm text-[#667085]">{existing.number}</div> : null}
|
||||
</div>
|
||||
{existing ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : <Plus className="h-5 w-5 text-[#a8adbc]" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{activeType ? (
|
||||
<DocumentFormDialog
|
||||
open={Boolean(activeType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveType(null);
|
||||
}}
|
||||
documentType={activeType}
|
||||
userId={user?.id ?? ''}
|
||||
token={token}
|
||||
existing={documentsByType.get(activeType) ?? null}
|
||||
onSaved={loadDocuments}
|
||||
/>
|
||||
) : null}
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
14
apps/frontend/app/family/[groupId]/page.tsx
Normal file
14
apps/frontend/app/family/[groupId]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { FamilyGroupView } from '@/components/family/family-group-view';
|
||||
|
||||
export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) {
|
||||
const { groupId } = use(params);
|
||||
return (
|
||||
<IdShell active="/family" wide>
|
||||
<FamilyGroupView groupId={groupId} />
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
97
apps/frontend/app/family/page.tsx
Normal file
97
apps/frontend/app/family/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronRight, Heart, Plus, UsersRound } from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
import { apiFetch, FamilyGroup, fetchFamilyGroups, getApiErrorMessage } from '@/lib/api';
|
||||
|
||||
export default function FamilyPage() {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [groups, setGroups] = useState<FamilyGroup[]>([]);
|
||||
const [name, setName] = useState('Моя семья');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
fetchFamilyGroups(user.id, token)
|
||||
.then((response) => setGroups(response.groups ?? []))
|
||||
.catch((error) => {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить семью');
|
||||
if (message) showToast(message);
|
||||
});
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
|
||||
async function createGroup() {
|
||||
if (!user || !token) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const group = await apiFetch<FamilyGroup>('/family/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ownerId: user.id, name })
|
||||
}, token);
|
||||
router.push(`/family/${group.id}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось создать семью');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<IdShell active="/family">
|
||||
<div className="py-20 text-center text-[#667085]">Загрузка...</div>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/family" wide>
|
||||
<p className="text-sm text-[#667085]">Семья</p>
|
||||
<h1 className="text-4xl font-medium tracking-tight">Семейный доступ</h1>
|
||||
<p className="mt-2 text-[#667085]">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
|
||||
|
||||
<div className="mt-8 flex gap-3">
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Название семьи" />
|
||||
<Button onClick={() => void createGroup()} disabled={creating}>
|
||||
{creating ? 'Создаём...' : (<><Plus className="h-4 w-4" />Создать</>)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
{groups.length ? groups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/family/${group.id}`)}
|
||||
className="flex w-full items-center gap-4 rounded-[24px] bg-[#f4f5f8] p-5 text-left transition hover:bg-[#eceef4]"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white">
|
||||
<Heart className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold">{group.name}</h2>
|
||||
<p className="text-sm text-[#667085]">{(group.members ?? []).length} участников</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rounded-[24px] bg-[#f4f5f8] p-5 text-[#667085]">
|
||||
<UsersRound className="mb-3 h-6 w-6" />
|
||||
Семейных групп пока нет
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
45
apps/frontend/app/globals.css
Normal file
45
apps/frontend/app/globals.css
Normal file
@@ -0,0 +1,45 @@
|
||||
@import "tailwindcss";
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #111827;
|
||||
--muted: #f4f5f8;
|
||||
--muted-foreground: #667085;
|
||||
--border: #e6e8ef;
|
||||
--primary: #20212b;
|
||||
--primary-foreground: #ffffff;
|
||||
--radius: 1.25rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.soft-card {
|
||||
border-radius: 24px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.id-shadow {
|
||||
box-shadow: 0 24px 70px rgb(22 26 43 / 12%);
|
||||
}
|
||||
28
apps/frontend/app/icon.tsx
Normal file
28
apps/frontend/app/icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const size = { width: 32, height: 32 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Icon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #6f58ff, #ffd2a8)',
|
||||
borderRadius: 8,
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
ID
|
||||
</div>
|
||||
),
|
||||
size
|
||||
);
|
||||
}
|
||||
18
apps/frontend/app/layout.tsx
Normal file
18
apps/frontend/app/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Providers } from './providers';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'MVK ID',
|
||||
description: 'Единый аккаунт для сервисов Lendry'
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
137
apps/frontend/app/page.tsx
Normal file
137
apps/frontend/app/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BadgeCheck, Bell, Car, FileText, Fingerprint, Heart, KeyRound, PawPrint, ShieldCheck, Smartphone, UserRound } from 'lucide-react';
|
||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { ActionTile, SectionTitle } from '@/components/id/action-tile';
|
||||
import { AdminBadge } from '@/components/id/admin-badge';
|
||||
import { AvatarUpload } from '@/components/id/avatar-upload';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
import { type DocumentTypeCode, indexDocumentsByType } from '@/lib/document-catalog';
|
||||
import { apiFetch, UserDocument } from '@/lib/api';
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const { user, token, refreshProfile, isPinLocked } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const contactLine = [user?.phone, user?.username ?? user?.email].filter(Boolean).join(' · ');
|
||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
try {
|
||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
|
||||
setDocuments(response.documents ?? []);
|
||||
} catch {
|
||||
setDocuments([]);
|
||||
}
|
||||
}, [isPinLocked, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && user && !isPinLocked) void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||||
|
||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||
|
||||
function openDocument(type: DocumentTypeCode) {
|
||||
setActiveDocumentType(type);
|
||||
}
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<IdShell active="/" wide>
|
||||
<div className="py-20 text-center text-[#667085]">Загрузка профиля...</div>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/" wide>
|
||||
<div className="flex flex-col items-center">
|
||||
{user ? (
|
||||
<AvatarUpload
|
||||
userId={user.id}
|
||||
displayName={user.displayName}
|
||||
hasAvatar={user.hasAvatar}
|
||||
token={token}
|
||||
onUpdated={refreshProfile}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-24 w-24 rounded-full bg-[#f4f5f8]" />
|
||||
)}
|
||||
<h1 className="mt-4 text-2xl font-semibold">{user?.displayName ?? 'Загрузка профиля...'}</h1>
|
||||
{user ? <AdminBadge user={user} className="mt-2" /> : null}
|
||||
<p className="text-sm text-[#667085]">{contactLine || 'Данные профиля загружаются'}</p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => router.push('/family')}>
|
||||
<Heart className="h-4 w-4 text-red-500" />
|
||||
Семья
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => router.push('/data')}>
|
||||
<UserRound className="h-4 w-4" />
|
||||
Профиль
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex w-full max-w-[450px] items-center justify-between rounded-[24px] bg-[#f0f2f7] p-5">
|
||||
<div>
|
||||
<h2 className="font-semibold">Защитите ваш аккаунт</h2>
|
||||
<p className="mt-1 text-sm text-[#667085]">Настройте PIN-код и способы восстановления</p>
|
||||
<Button className="mt-4 rounded-xl" size="sm" onClick={() => router.push('/security')}>
|
||||
Перейти к защите
|
||||
</Button>
|
||||
</div>
|
||||
<BadgeCheck className="h-20 w-20 text-[#68b8ff]" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[720px]">
|
||||
<SectionTitle>Защита аккаунта</SectionTitle>
|
||||
<div className="grid grid-cols-3 gap-4 md:grid-cols-5">
|
||||
<ActionTile icon={ShieldCheck} label="Способы входа" onClick={() => router.push('/security')} />
|
||||
<ActionTile icon={Smartphone} label="Проверить устройства" onClick={() => router.push('/security')} />
|
||||
<ActionTile icon={Bell} label="Активность" onClick={() => router.push('/security')} />
|
||||
<ActionTile icon={KeyRound} label="Добавить запасную почту" onClick={() => router.push('/security')} />
|
||||
<ActionTile icon={Fingerprint} label="Привязать лицо или отпечаток" onClick={() => router.push('/security')} />
|
||||
</div>
|
||||
|
||||
<SectionTitle>Документы</SectionTitle>
|
||||
<div className="grid grid-cols-3 gap-4 md:grid-cols-6">
|
||||
<ActionTile icon={FileText} label="Все" onClick={() => router.push('/documents')} />
|
||||
<ActionTile icon={FileText} label="Паспорт" onClick={() => openDocument('PASSPORT_RF')} />
|
||||
<ActionTile icon={FileText} label="Загран" onClick={() => openDocument('FOREIGN_PASSPORT')} />
|
||||
<ActionTile icon={Car} label="ВУ" onClick={() => openDocument('DRIVER_LICENSE')} />
|
||||
</div>
|
||||
|
||||
<AddressQuickSection layout="home" isPinLocked={isPinLocked} isReady={isReady} />
|
||||
|
||||
<SectionTitle>Семья</SectionTitle>
|
||||
<div className="grid grid-cols-3 gap-4 md:grid-cols-6">
|
||||
<ActionTile icon={UserRound} label="Вы" onClick={() => router.push('/family')} />
|
||||
<ActionTile icon={Heart} label="Добавить" onClick={() => router.push('/family')} />
|
||||
<ActionTile icon={PawPrint} label="Питомцы" onClick={() => router.push('/family')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeDocumentType && user ? (
|
||||
<DocumentFormDialog
|
||||
open={Boolean(activeDocumentType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveDocumentType(null);
|
||||
}}
|
||||
documentType={activeDocumentType}
|
||||
userId={user.id}
|
||||
token={token}
|
||||
existing={documentsByType.get(activeDocumentType) ?? null}
|
||||
onSaved={loadDocuments}
|
||||
/>
|
||||
) : null}
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
20
apps/frontend/app/providers.tsx
Normal file
20
apps/frontend/app/providers.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { AuthProvider } from '@/components/id/auth-provider';
|
||||
import { RealtimeProvider } from '@/components/notifications/realtime-provider';
|
||||
import { PublicSettingsProvider } from '@/components/id/public-settings-provider';
|
||||
import { ProjectHead } from '@/components/id/project-head';
|
||||
import { AppToastProvider } from '@/components/id/toast-provider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AppToastProvider>
|
||||
<PublicSettingsProvider>
|
||||
<ProjectHead />
|
||||
<AuthProvider>
|
||||
<RealtimeProvider>{children}</RealtimeProvider>
|
||||
</AuthProvider>
|
||||
</PublicSettingsProvider>
|
||||
</AppToastProvider>
|
||||
);
|
||||
}
|
||||
277
apps/frontend/app/security/page.tsx
Normal file
277
apps/frontend/app/security/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
Laptop,
|
||||
LogOut,
|
||||
Mail,
|
||||
Monitor,
|
||||
type LucideIcon,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Speaker,
|
||||
Tv
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ActiveDevice, apiFetch, SignInEvent } from '@/lib/api';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
const deviceIcons: Record<string, LucideIcon> = {
|
||||
WEB: Monitor,
|
||||
DESKTOP: Monitor,
|
||||
IOS: Smartphone,
|
||||
ANDROID: Smartphone,
|
||||
TV: Tv,
|
||||
SMART_SPEAKER: Speaker,
|
||||
OTHER: Laptop
|
||||
};
|
||||
|
||||
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | null;
|
||||
|
||||
const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placeholder: string; type: string }> = {
|
||||
pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр', type: 'password' },
|
||||
password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' },
|
||||
backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' },
|
||||
backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' }
|
||||
};
|
||||
|
||||
export default function SecurityPage() {
|
||||
const router = useRouter();
|
||||
const { user, token, logout } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [devices, setDevices] = useState<ActiveDevice[]>([]);
|
||||
const [history, setHistory] = useState<SignInEvent[]>([]);
|
||||
const [isSecurityLoading, setIsSecurityLoading] = useState(true);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [dialogValue, setDialogValue] = useState('');
|
||||
const [isDialogSaving, setIsDialogSaving] = useState(false);
|
||||
|
||||
const loadSecurity = useCallback(async () => {
|
||||
if (!user || !token) return;
|
||||
setIsSecurityLoading(true);
|
||||
try {
|
||||
const [devicesResponse, historyResponse] = await Promise.all([
|
||||
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token)
|
||||
]);
|
||||
setDevices(devicesResponse.devices ?? []);
|
||||
setHistory(historyResponse.events ?? []);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
|
||||
} finally {
|
||||
setIsSecurityLoading(false);
|
||||
}
|
||||
}, [showToast, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !user) return;
|
||||
void loadSecurity();
|
||||
}, [isReady, loadSecurity, user]);
|
||||
|
||||
async function revokeDevice(deviceId: string) {
|
||||
if (!user || !token) return;
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/devices/${deviceId}/revoke`, { method: 'POST' }, token);
|
||||
setDevices((current) => current.filter((device) => device.id !== deviceId));
|
||||
showToast('Сессии устройства завершены');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось завершить сессию');
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAllSessions() {
|
||||
if (!user || !token) return;
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/revoke-all-sessions`, { method: 'POST' }, token);
|
||||
showToast('Все сессии завершены');
|
||||
logout();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось выйти везде');
|
||||
} finally {
|
||||
setIsRevoking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDialog() {
|
||||
if (!user || !token || !dialogMode) return;
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
if (dialogMode === 'pin') {
|
||||
await apiFetch(`/security/users/${user.id}/pin/setup`, { method: 'POST', body: JSON.stringify({ pin: dialogValue.trim() }) }, token);
|
||||
showToast('PIN-код установлен');
|
||||
} else if (dialogMode === 'password') {
|
||||
await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token);
|
||||
showToast('Пароль установлен');
|
||||
} else if (dialogMode === 'backupEmail') {
|
||||
await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupEmail: dialogValue.trim() }) }, token);
|
||||
showToast('Резервная почта добавлена');
|
||||
} else if (dialogMode === 'backupPhone') {
|
||||
await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupPhone: dialogValue.trim() }) }, token);
|
||||
showToast('Резервный телефон добавлен');
|
||||
}
|
||||
setDialogMode(null);
|
||||
setDialogValue('');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось сохранить');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(mode: Exclude<DialogMode, null>) {
|
||||
setDialogValue('');
|
||||
setDialogMode(mode);
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/security">
|
||||
<p className="text-sm text-[#667085]">Безопасность</p>
|
||||
<h1 className="text-4xl font-medium tracking-tight">Устройства</h1>
|
||||
<p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p>
|
||||
|
||||
<div className="mt-8 space-y-2">
|
||||
{isSecurityLoading ? (
|
||||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
|
||||
) : devices.length ? (
|
||||
devices.map((device) => {
|
||||
const Icon = deviceIcons[device.type] ?? Laptop;
|
||||
return (
|
||||
<div key={device.id} className="flex items-center gap-4 rounded-[20px] bg-[#f4f5f8] px-4 py-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{device.name}</span>
|
||||
<span className="text-xs text-[#667085]">{device.lastIp ?? 'IP не сохранён'} · {formatDate(device.lastSeenAt)}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="shrink-0 text-[#d14343] hover:bg-[#fbeaea]" onClick={() => revokeDevice(device.id)}>
|
||||
Выйти
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Активных устройств пока нет</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-1">
|
||||
<button type="button" onClick={revokeAllSessions} disabled={isRevoking || !user} className="flex w-full items-center gap-4 border-t border-[#eceef4] py-4 text-left disabled:opacity-60">
|
||||
<LogOut className="h-6 w-6" />
|
||||
<span>{isRevoking ? 'Выходим...' : 'Выйти везде'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Способ входа в профиль</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
<button type="button" onClick={() => openDialog('pin')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
|
||||
<Fingerprint className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">PIN-код</p>
|
||||
<p className="text-sm text-[#667085]">Установить или изменить PIN для входа</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
<button type="button" onClick={() => openDialog('password')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Пароль</p>
|
||||
<p className="text-sm text-[#667085]">{user?.email || user?.phone ? 'Добавить пароль как способ входа' : 'Установить пароль'}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Способы восстановления</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
<button type="button" onClick={() => openDialog('backupEmail')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
|
||||
<Mail className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Резервная почта</p>
|
||||
<p className="text-sm text-[#667085]">{user?.backupEmail || 'Не добавлена'}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
<button type="button" onClick={() => openDialog('backupPhone')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
|
||||
<Phone className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Резервный телефон</p>
|
||||
<p className="text-sm text-[#667085]">{user?.backupPhone || 'Не добавлен'}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Активность</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{isSecurityLoading ? (
|
||||
<div className="text-[#667085]">Загружаем историю входов...</div>
|
||||
) : history.length ? (
|
||||
history.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-4 border-b border-[#eceef4] pb-3">
|
||||
<Clock3 className="h-5 w-5" />
|
||||
<span className="flex-1">{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</span>
|
||||
<span className="text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[#667085]">История входов пока пуста</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(open) => (open ? null : setDialogMode(null))}>
|
||||
<DialogContent>
|
||||
{dialogMode ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<ShieldCheck className="h-5 w-5 text-[#667085]" />
|
||||
<Input
|
||||
className="border-0 bg-transparent focus:ring-0"
|
||||
type={dialogConfig[dialogMode].type}
|
||||
placeholder={dialogConfig[dialogMode].placeholder}
|
||||
value={dialogValue}
|
||||
onChange={(event) => setDialogValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
|
||||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
19
apps/frontend/components.json
Normal file
19
apps/frontend/components.json
Normal 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"
|
||||
}
|
||||
299
apps/frontend/components/addresses/address-form-dialog.tsx
Normal file
299
apps/frontend/components/addresses/address-form-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
apps/frontend/components/addresses/address-map-picker.tsx
Normal file
102
apps/frontend/components/addresses/address-map-picker.tsx
Normal 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: '© <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>
|
||||
);
|
||||
}
|
||||
154
apps/frontend/components/addresses/address-quick-section.tsx
Normal file
154
apps/frontend/components/addresses/address-quick-section.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
502
apps/frontend/components/documents/document-form-dialog.tsx
Normal file
502
apps/frontend/components/documents/document-form-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/frontend/components/documents/document-photo-gallery.tsx
Normal file
105
apps/frontend/components/documents/document-photo-gallery.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
109
apps/frontend/components/documents/document-photo-viewer.tsx
Normal file
109
apps/frontend/components/documents/document-photo-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
apps/frontend/components/documents/user-documents-dialog.tsx
Normal file
112
apps/frontend/components/documents/user-documents-dialog.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
805
apps/frontend/components/family/family-group-view.tsx
Normal file
805
apps/frontend/components/family/family-group-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
apps/frontend/components/id/action-tile.tsx
Normal file
42
apps/frontend/components/id/action-tile.tsx
Normal 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>;
|
||||
}
|
||||
24
apps/frontend/components/id/admin-badge.tsx
Normal file
24
apps/frontend/components/id/admin-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/frontend/components/id/admin-nav.tsx
Normal file
41
apps/frontend/components/id/admin-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/frontend/components/id/admin-shell.tsx
Normal file
47
apps/frontend/components/id/admin-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
473
apps/frontend/components/id/auth-provider.tsx
Normal file
473
apps/frontend/components/id/auth-provider.tsx
Normal 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;
|
||||
}
|
||||
127
apps/frontend/components/id/avatar-upload.tsx
Normal file
127
apps/frontend/components/id/avatar-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/frontend/components/id/brand-logo.tsx
Normal file
43
apps/frontend/components/id/brand-logo.tsx
Normal 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>;
|
||||
}
|
||||
62
apps/frontend/components/id/pin-lock-modal.tsx
Normal file
62
apps/frontend/components/id/pin-lock-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/frontend/components/id/project-head.tsx
Normal file
18
apps/frontend/components/id/project-head.tsx
Normal 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;
|
||||
}
|
||||
64
apps/frontend/components/id/public-settings-provider.tsx
Normal file
64
apps/frontend/components/id/public-settings-provider.tsx
Normal 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);
|
||||
}
|
||||
20
apps/frontend/components/id/shell.tsx
Normal file
20
apps/frontend/components/id/shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/frontend/components/id/sidebar.tsx
Normal file
57
apps/frontend/components/id/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/frontend/components/id/toast-provider.tsx
Normal file
58
apps/frontend/components/id/toast-provider.tsx
Normal 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;
|
||||
}
|
||||
125
apps/frontend/components/id/user-menu.tsx
Normal file
125
apps/frontend/components/id/user-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
apps/frontend/components/notifications/notification-bell.tsx
Normal file
233
apps/frontend/components/notifications/notification-bell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/frontend/components/notifications/realtime-provider.tsx
Normal file
110
apps/frontend/components/notifications/realtime-provider.tsx
Normal 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;
|
||||
}
|
||||
16
apps/frontend/components/ui/avatar.tsx
Normal file
16
apps/frontend/components/ui/avatar.tsx
Normal 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} />;
|
||||
}
|
||||
38
apps/frontend/components/ui/button.tsx
Normal file
38
apps/frontend/components/ui/button.tsx
Normal 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} />;
|
||||
}
|
||||
22
apps/frontend/components/ui/card.tsx
Normal file
22
apps/frontend/components/ui/card.tsx
Normal 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} />;
|
||||
}
|
||||
34
apps/frontend/components/ui/command.tsx
Normal file
34
apps/frontend/components/ui/command.tsx
Normal 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} />;
|
||||
}
|
||||
30
apps/frontend/components/ui/dialog.tsx
Normal file
30
apps/frontend/components/ui/dialog.tsx
Normal 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} />;
|
||||
}
|
||||
15
apps/frontend/components/ui/dropdown-menu.tsx
Normal file
15
apps/frontend/components/ui/dropdown-menu.tsx
Normal 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} />;
|
||||
}
|
||||
20
apps/frontend/components/ui/form.tsx
Normal file
20
apps/frontend/components/ui/form.tsx
Normal 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} />;
|
||||
}
|
||||
15
apps/frontend/components/ui/input.tsx
Normal file
15
apps/frontend/components/ui/input.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
apps/frontend/components/ui/otp-input.tsx
Normal file
91
apps/frontend/components/ui/otp-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
apps/frontend/components/ui/phone-input.tsx
Normal file
140
apps/frontend/components/ui/phone-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/frontend/components/ui/popover.tsx
Normal file
20
apps/frontend/components/ui/popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/frontend/components/ui/table.tsx
Normal file
26
apps/frontend/components/ui/table.tsx
Normal 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} />;
|
||||
}
|
||||
23
apps/frontend/components/ui/tabs.tsx
Normal file
23
apps/frontend/components/ui/tabs.tsx
Normal 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} />;
|
||||
}
|
||||
17
apps/frontend/components/ui/toast.tsx
Normal file
17
apps/frontend/components/ui/toast.tsx
Normal 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} />;
|
||||
}
|
||||
39
apps/frontend/hooks/use-avatar-url.ts
Normal file
39
apps/frontend/hooks/use-avatar-url.ts
Normal 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 };
|
||||
}
|
||||
25
apps/frontend/hooks/use-require-auth.ts
Normal file
25
apps/frontend/hooks/use-require-auth.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
28
apps/frontend/lib/address-catalog.ts
Normal file
28
apps/frontend/lib/address-catalog.ts
Normal 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
582
apps/frontend/lib/api.ts
Normal 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';
|
||||
41
apps/frontend/lib/authenticated-media.ts
Normal file
41
apps/frontend/lib/authenticated-media.ts
Normal 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;
|
||||
}
|
||||
115
apps/frontend/lib/chat-media.ts
Normal file
115
apps/frontend/lib/chat-media.ts
Normal 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';
|
||||
}
|
||||
308
apps/frontend/lib/document-catalog.ts
Normal file
308
apps/frontend/lib/document-catalog.ts
Normal 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;
|
||||
}
|
||||
76
apps/frontend/lib/geocoding.ts
Normal file
76
apps/frontend/lib/geocoding.ts
Normal 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(', ');
|
||||
}
|
||||
62
apps/frontend/lib/system-settings-catalog.ts
Normal file
62
apps/frontend/lib/system-settings-catalog.ts
Normal 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));
|
||||
}
|
||||
6
apps/frontend/lib/utils.ts
Normal file
6
apps/frontend/lib/utils.ts
Normal 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
4
apps/frontend/next-env.d.ts
vendored
Normal 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.
|
||||
7
apps/frontend/next.config.ts
Normal file
7
apps/frontend/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
typedRoutes: false
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
42
apps/frontend/package.json
Normal file
42
apps/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
apps/frontend/postcss.config.mjs
Normal file
7
apps/frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
apps/frontend/public/flags/by.svg
Normal file
1
apps/frontend/public/flags/by.svg
Normal 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 |
1
apps/frontend/public/flags/kz.svg
Normal file
1
apps/frontend/public/flags/kz.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.4 KiB |
1
apps/frontend/public/flags/ru.svg
Normal file
1
apps/frontend/public/flags/ru.svg
Normal 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 |
25
apps/frontend/tsconfig.json
Normal file
25
apps/frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user