more fix and update
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCog } from 'lucide-react';
|
||||
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCheck, 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';
|
||||
@@ -78,6 +78,23 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnsuspend(user: AdminUser) {
|
||||
if (!token) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: 'ACTIVE' })
|
||||
}, 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);
|
||||
@@ -212,6 +229,9 @@ export default function AdminUsersPage() {
|
||||
<Button variant="secondary" size="icon" aria-label="Заблокировать" disabled={actionLoading || user.status === 'SUSPENDED'} onClick={() => void handleSuspend(user)}>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="icon" aria-label="Разблокировать" disabled={actionLoading || user.status !== 'SUSPENDED'} onClick={() => void handleUnsuspend(user)}>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{currentUser?.canViewUserDocuments ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,13 +13,15 @@ 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 { DatePicker } from '@/components/ui/date-picker';
|
||||
import { PhoneInput, parseE164Phone, phoneCountries, toE164 } from '@/components/ui/phone-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';
|
||||
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument, UserProfileResponse } from '@/lib/api';
|
||||
|
||||
function Row({
|
||||
icon: Icon,
|
||||
@@ -72,6 +74,11 @@ export default function DataPage() {
|
||||
const [backupEmail, setBackupEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [backupPhone, setBackupPhone] = useState('');
|
||||
const [birthDate, setBirthDate] = useState<string | undefined>();
|
||||
const [phoneCountry, setPhoneCountry] = useState(phoneCountries[0]);
|
||||
const [phoneDigits, setPhoneDigits] = useState('');
|
||||
const [backupPhoneCountry, setBackupPhoneCountry] = useState(phoneCountries[0]);
|
||||
const [backupPhoneDigits, setBackupPhoneDigits] = useState('');
|
||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
||||
@@ -94,10 +101,25 @@ export default function DataPage() {
|
||||
setDisplayName(user.displayName);
|
||||
setEmail(user.email ?? '');
|
||||
setBackupEmail(user.backupEmail ?? '');
|
||||
setPhone(user.phone ?? '');
|
||||
setBackupPhone(user.backupPhone ?? '');
|
||||
const parsedPhone = parseE164Phone(user.phone ?? '');
|
||||
setPhoneCountry(parsedPhone.country);
|
||||
setPhoneDigits(parsedPhone.digits);
|
||||
setPhone(parsedPhone.digits ? toE164(parsedPhone.country, parsedPhone.digits) : '');
|
||||
const parsedBackupPhone = parseE164Phone(user.backupPhone ?? '');
|
||||
setBackupPhoneCountry(parsedBackupPhone.country);
|
||||
setBackupPhoneDigits(parsedBackupPhone.digits);
|
||||
setBackupPhone(parsedBackupPhone.digits ? toE164(parsedBackupPhone.country, parsedBackupPhone.digits) : '');
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
void apiFetch<UserProfileResponse>(`/profile/users/${user.id}`, {}, token)
|
||||
.then((profile) => {
|
||||
if (profile.birthDate) setBirthDate(profile.birthDate);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [isPinLocked, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && user && !isPinLocked) void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||||
@@ -116,14 +138,20 @@ export default function DataPage() {
|
||||
try {
|
||||
await apiFetch(`/profile/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ firstName: firstName || undefined, lastName: rest.join(' ') || undefined })
|
||||
body: JSON.stringify({
|
||||
firstName: firstName || undefined,
|
||||
lastName: rest.join(' ') || undefined,
|
||||
birthDate: birthDate || undefined
|
||||
})
|
||||
}, token);
|
||||
|
||||
const contacts: Record<string, string> = {};
|
||||
const nextPhone = phoneDigits ? toE164(phoneCountry, phoneDigits) : '';
|
||||
const nextBackupPhone = backupPhoneDigits ? toE164(backupPhoneCountry, backupPhoneDigits) : '';
|
||||
if (email.trim() && email.trim() !== (user.email ?? '')) contacts.email = email.trim();
|
||||
if (phone.trim() && phone.trim() !== (user.phone ?? '')) contacts.phone = phone.trim();
|
||||
if (nextPhone && nextPhone !== (user.phone ?? '')) contacts.phone = nextPhone;
|
||||
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
|
||||
if (backupPhone.trim() && backupPhone.trim() !== (user.backupPhone ?? '')) contacts.backupPhone = backupPhone.trim();
|
||||
if (nextBackupPhone && nextBackupPhone !== (user.backupPhone ?? '')) contacts.backupPhone = nextBackupPhone;
|
||||
|
||||
if (Object.keys(contacts).length > 0) {
|
||||
await apiFetch(`/profile/users/${user.id}/contacts`, {
|
||||
@@ -177,11 +205,31 @@ export default function DataPage() {
|
||||
<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="Дата рождения" />
|
||||
<DatePicker value={birthDate} onChange={setBirthDate} />
|
||||
<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)} />
|
||||
<PhoneInput
|
||||
variant="light"
|
||||
country={phoneCountry}
|
||||
value={phoneDigits}
|
||||
onCountryChange={setPhoneCountry}
|
||||
onValueChange={(digits) => {
|
||||
setPhoneDigits(digits);
|
||||
setPhone(digits ? toE164(phoneCountry, digits) : '');
|
||||
}}
|
||||
required={false}
|
||||
/>
|
||||
<PhoneInput
|
||||
variant="light"
|
||||
country={backupPhoneCountry}
|
||||
value={backupPhoneDigits}
|
||||
onCountryChange={setBackupPhoneCountry}
|
||||
onValueChange={(digits) => {
|
||||
setBackupPhoneDigits(digits);
|
||||
setBackupPhone(digits ? toE164(backupPhoneCountry, digits) : '');
|
||||
}}
|
||||
required={false}
|
||||
/>
|
||||
</div>
|
||||
<Button className="mt-4" onClick={updateProfile} disabled={isSaving}>
|
||||
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
@import "react-day-picker/style.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
@@ -23,6 +24,11 @@ body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.rdp-root {
|
||||
--rdp-accent-color: #111827;
|
||||
--rdp-accent-background-color: #f4f5f8;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
@@ -24,7 +23,9 @@ 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 { OtpInput } from '@/components/ui/otp-input';
|
||||
import { ActiveDevice, apiFetch, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
|
||||
function formatDate(value: string) {
|
||||
@@ -46,9 +47,9 @@ const deviceIcons: Record<string, LucideIcon> = {
|
||||
OTHER: Laptop
|
||||
};
|
||||
|
||||
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | null;
|
||||
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | 'totpSetup' | 'totpDisable' | null;
|
||||
|
||||
const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placeholder: string; type: string }> = {
|
||||
const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable'>, { 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' },
|
||||
@@ -56,8 +57,7 @@ const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placehold
|
||||
};
|
||||
|
||||
export default function SecurityPage() {
|
||||
const router = useRouter();
|
||||
const { user, token, logout } = useAuth();
|
||||
const { user, token } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [devices, setDevices] = useState<ActiveDevice[]>([]);
|
||||
@@ -67,17 +67,22 @@ export default function SecurityPage() {
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [dialogValue, setDialogValue] = useState('');
|
||||
const [isDialogSaving, setIsDialogSaving] = useState(false);
|
||||
const [totpEnabled, setTotpEnabled] = useState(false);
|
||||
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
|
||||
const loadSecurity = useCallback(async () => {
|
||||
if (!user || !token) return;
|
||||
setIsSecurityLoading(true);
|
||||
try {
|
||||
const [devicesResponse, historyResponse] = await Promise.all([
|
||||
const [devicesResponse, historyResponse, totpResponse] = await Promise.all([
|
||||
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token)
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token),
|
||||
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token)
|
||||
]);
|
||||
setDevices(devicesResponse.devices ?? []);
|
||||
setHistory(historyResponse.events ?? []);
|
||||
setTotpEnabled(Boolean(totpResponse.isEnabled));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
|
||||
} finally {
|
||||
@@ -106,8 +111,8 @@ export default function SecurityPage() {
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/revoke-all-sessions`, { method: 'POST' }, token);
|
||||
showToast('Все сессии завершены');
|
||||
logout();
|
||||
showToast('Выход выполнен на всех остальных устройствах');
|
||||
await loadSecurity();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось выйти везде');
|
||||
} finally {
|
||||
@@ -115,6 +120,64 @@ export default function SecurityPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openTotpDialog() {
|
||||
if (!user || !token) return;
|
||||
if (totpEnabled) {
|
||||
setTotpCode('');
|
||||
setDialogMode('totpDisable');
|
||||
return;
|
||||
}
|
||||
if (totpSetup) {
|
||||
setTotpCode('');
|
||||
setDialogMode('totpSetup');
|
||||
return;
|
||||
}
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
const setup = await apiFetch<TotpSetupResponse>(`/security/users/${user.id}/totp/setup`, { method: 'POST' }, token);
|
||||
setTotpSetup(setup);
|
||||
setTotpCode('');
|
||||
setDialogMode('totpSetup');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось начать настройку аутентификатора');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTotpSetup(code: string) {
|
||||
if (!user || !token) return;
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/totp/enable`, { method: 'POST', body: JSON.stringify({ code }) }, token);
|
||||
setTotpEnabled(true);
|
||||
setDialogMode(null);
|
||||
setTotpSetup(null);
|
||||
setTotpCode('');
|
||||
showToast('Приложение-аутентификатор подключено');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось подтвердить код');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTotpDisable(code: string) {
|
||||
if (!user || !token) return;
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
await apiFetch(`/security/users/${user.id}/totp/disable`, { method: 'POST', body: JSON.stringify({ code }) }, token);
|
||||
setTotpEnabled(false);
|
||||
setDialogMode(null);
|
||||
setTotpCode('');
|
||||
showToast('Приложение-аутентификатор отключено');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось отключить аутентификатор');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDialog() {
|
||||
if (!user || !token || !dialogMode) return;
|
||||
setIsDialogSaving(true);
|
||||
@@ -183,8 +246,25 @@ export default function SecurityPage() {
|
||||
<LogOut className="h-6 w-6" />
|
||||
<span>{isRevoking ? 'Выходим...' : 'Выйти везде'}</span>
|
||||
</button>
|
||||
<p className="text-sm text-[#667085]">Завершит сессии на всех устройствах, кроме текущего. Выход с этого устройства — через меню профиля.</p>
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Дополнительная защита</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
<button type="button" onClick={() => void openTotpDialog()} disabled={isDialogSaving} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6] disabled:opacity-60">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Приложение-аутентификатор</p>
|
||||
<p className="text-sm text-[#667085]">
|
||||
{totpEnabled ? 'Google Authenticator и аналоги подключены' : 'Подключите OTP-коды через Google Authenticator'}
|
||||
</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">
|
||||
@@ -248,9 +328,45 @@ export default function SecurityPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(open) => (open ? null : setDialogMode(null))}>
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogMode(null);
|
||||
if (dialogMode !== 'totpSetup' || totpEnabled) {
|
||||
setTotpSetup(null);
|
||||
}
|
||||
setTotpCode('');
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
{dialogMode ? (
|
||||
{dialogMode === 'totpSetup' && totpSetup ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Подключить аутентификатор</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-[#667085]">Отсканируйте QR-код в Google Authenticator или аналоге, затем введите 6-значный код.</p>
|
||||
<div className="flex justify-center rounded-2xl bg-white p-4">
|
||||
<QRCodeSVG value={totpSetup.otpauthUrl} size={180} />
|
||||
</div>
|
||||
<p className="break-all rounded-2xl bg-[#f4f5f8] px-4 py-3 text-sm text-[#667085]">
|
||||
Ключ: <span className="font-mono text-[#111827]">{totpSetup.secret}</span>
|
||||
</p>
|
||||
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpSetup(code)} disabled={isDialogSaving} />
|
||||
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialogMode === 'totpDisable' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Отключить аутентификатор</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-[#667085]">Введите код из приложения, чтобы отключить двухфакторную аутентификацию.</p>
|
||||
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpDisable(code)} disabled={isDialogSaving} />
|
||||
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
withDocumentPhotos,
|
||||
type DocumentTypeCode
|
||||
} from '@/lib/document-catalog';
|
||||
import { apiFetch, getApiErrorMessage, UserDocument } from '@/lib/api';
|
||||
import { apiFetch, getApiErrorMessage, uploadMediaObject, UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PresignedUploadResponse {
|
||||
@@ -266,12 +266,7 @@ export function DocumentFormDialog({
|
||||
token
|
||||
);
|
||||
|
||||
const uploadResponse = await fetch(presigned.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': file.type },
|
||||
body: file
|
||||
});
|
||||
if (!uploadResponse.ok) throw new Error('Не удалось загрузить фото');
|
||||
await uploadMediaObject(presigned, file, file.type);
|
||||
|
||||
const nextPhotos = [...currentPhotos, presigned.storageKey];
|
||||
photoStorageKeysRef.current = nextPhotos;
|
||||
|
||||
@@ -31,14 +31,17 @@ import {
|
||||
ChatRoom,
|
||||
createChatRoom,
|
||||
FamilyGroup,
|
||||
FamilyInviteCandidate,
|
||||
fetchChatMessages,
|
||||
fetchChatRooms,
|
||||
fetchFamilyGroup,
|
||||
getApiErrorMessage,
|
||||
searchFamilyInviteUsers,
|
||||
sendChatMessage,
|
||||
sendFamilyInvite,
|
||||
setChatRoomMuted,
|
||||
updateFamilyGroup,
|
||||
uploadMediaObject,
|
||||
voteChatPoll
|
||||
} from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -63,6 +66,8 @@ const EMOJIS = ['😀', '😂', '❤️', '👍', '🎉', '🔥', '😍', '🙏'
|
||||
interface PresignedUploadResponse {
|
||||
uploadUrl: string;
|
||||
storageKey: string;
|
||||
expiresAt?: string;
|
||||
uploadToken?: string;
|
||||
}
|
||||
|
||||
interface LoadedChatMedia {
|
||||
@@ -108,7 +113,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [createRoomOpen, setCreateRoomOpen] = useState(false);
|
||||
const [pollOpen, setPollOpen] = useState(false);
|
||||
const [inviteTarget, setInviteTarget] = useState('');
|
||||
const [inviteQuery, setInviteQuery] = useState('');
|
||||
const [inviteResults, setInviteResults] = useState<FamilyInviteCandidate[]>([]);
|
||||
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
|
||||
const [inviteSearching, setInviteSearching] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [newRoomName, setNewRoomName] = useState('');
|
||||
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
|
||||
@@ -300,7 +308,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
|
||||
token
|
||||
);
|
||||
await fetch(presigned.uploadUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file });
|
||||
await uploadMediaObject(presigned, file, file.type);
|
||||
await apiFetch(`/media/families/${groupId}/avatar/confirm`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ storageKey: presigned.storageKey })
|
||||
@@ -313,17 +321,38 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}
|
||||
|
||||
async function submitInvite() {
|
||||
if (!token || !inviteTarget.trim()) return;
|
||||
if (!token || !selectedInviteUser) return;
|
||||
try {
|
||||
await sendFamilyInvite(groupId, inviteTarget.trim(), token);
|
||||
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
|
||||
setInviteOpen(false);
|
||||
setInviteTarget('');
|
||||
setInviteQuery('');
|
||||
setInviteResults([]);
|
||||
setSelectedInviteUser(null);
|
||||
showToast('Приглашение отправлено');
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!inviteOpen || !token || selectedInviteUser) {
|
||||
return;
|
||||
}
|
||||
const query = inviteQuery.trim();
|
||||
if (query.length < 2) {
|
||||
setInviteResults([]);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setInviteSearching(true);
|
||||
void searchFamilyInviteUsers(groupId, query, token)
|
||||
.then((response) => setInviteResults(response.users ?? []))
|
||||
.catch(() => setInviteResults([]))
|
||||
.finally(() => setInviteSearching(false));
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [groupId, inviteOpen, inviteQuery, selectedInviteUser, token]);
|
||||
|
||||
async function submitCreateRoom() {
|
||||
if (!token || !newRoomName.trim()) return;
|
||||
try {
|
||||
@@ -377,11 +406,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
},
|
||||
token
|
||||
);
|
||||
await fetch(presigned.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: file
|
||||
});
|
||||
await uploadMediaObject(presigned, file, contentType);
|
||||
const message = await sendChatMessage(
|
||||
activeRoomId,
|
||||
{
|
||||
@@ -747,11 +772,72 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
event.target.value = '';
|
||||
}} />
|
||||
|
||||
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
||||
<Dialog open={inviteOpen} onOpenChange={(open) => {
|
||||
setInviteOpen(open);
|
||||
if (!open) {
|
||||
setInviteQuery('');
|
||||
setInviteResults([]);
|
||||
setSelectedInviteUser(null);
|
||||
}
|
||||
}}>
|
||||
<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>
|
||||
{selectedInviteUser ? (
|
||||
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
|
||||
<p className="font-medium">{selectedInviteUser.displayName}</p>
|
||||
<p className="text-sm text-[#667085]">
|
||||
{selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'}
|
||||
</p>
|
||||
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
|
||||
Выбрать другого пользователя
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="ФИО, почта, телефон или логин"
|
||||
value={inviteQuery}
|
||||
onChange={(event) => setInviteQuery(event.target.value)}
|
||||
/>
|
||||
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
|
||||
{inviteSearching ? (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Ищем пользователей...
|
||||
</div>
|
||||
) : inviteQuery.trim().length < 2 ? (
|
||||
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска</p>
|
||||
) : inviteResults.length ? (
|
||||
inviteResults.map((candidate) => (
|
||||
<button
|
||||
key={candidate.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
|
||||
onClick={() => {
|
||||
setSelectedInviteUser(candidate);
|
||||
setInviteResults([]);
|
||||
}}
|
||||
>
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback>{candidate.displayName.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{candidate.displayName}</p>
|
||||
<p className="truncate text-sm text-[#667085]">
|
||||
{candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="px-4 py-3 text-sm text-[#667085]">Пользователи не найдены</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser} onClick={() => void submitInvite()}>
|
||||
Отправить приглашение
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -39,7 +39,10 @@ interface AuthContextValue {
|
||||
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
|
||||
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
|
||||
loginWithLdap: (username: string, password: string) => Promise<AuthTokens>;
|
||||
beginTotpLogin: (recipient: string) => Promise<string>;
|
||||
verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise<AuthTokens>;
|
||||
completePin: (sessionId: string, pin: string) => Promise<void>;
|
||||
applyLoginAuth: (auth: AuthTokens) => AuthTokens;
|
||||
register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
|
||||
refreshProfile: () => Promise<void>;
|
||||
logout: () => void;
|
||||
@@ -381,6 +384,50 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
[saveSession]
|
||||
);
|
||||
|
||||
const beginTotpLogin = React.useCallback(async (recipient: string) => {
|
||||
const response = await apiFetch<{ totpChallengeToken: string }>('/auth/totp/begin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipient,
|
||||
fingerprint: getDeviceFingerprint(),
|
||||
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
|
||||
deviceType: 'WEB'
|
||||
})
|
||||
});
|
||||
return response.totpChallengeToken;
|
||||
}, []);
|
||||
|
||||
const verifyTotpLogin = React.useCallback(
|
||||
async (totpChallengeToken: string, code: string) => {
|
||||
const auth = await apiFetch<AuthTokens>('/auth/totp/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ totpChallengeToken, code })
|
||||
});
|
||||
if (auth.pinVerified) {
|
||||
saveSession(auth);
|
||||
} else {
|
||||
persistPartialAuth(auth);
|
||||
setHasStoredSession(true);
|
||||
}
|
||||
return auth;
|
||||
},
|
||||
[saveSession]
|
||||
);
|
||||
|
||||
const applyLoginAuth = React.useCallback(
|
||||
(auth: AuthTokens) => {
|
||||
if (auth.pinVerified) {
|
||||
saveSession(auth);
|
||||
} else {
|
||||
persistPartialAuth(auth);
|
||||
setHasStoredSession(true);
|
||||
activatePinLock(auth.sessionId);
|
||||
}
|
||||
return auth;
|
||||
},
|
||||
[activatePinLock, saveSession]
|
||||
);
|
||||
|
||||
const completePin = React.useCallback(
|
||||
async (sessionId: string, pin: string) => {
|
||||
const response = await apiFetch<PinVerificationResponse>('/auth/pin/verify', {
|
||||
@@ -452,7 +499,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
verifyLoginOtp,
|
||||
loginWithPassword,
|
||||
loginWithLdap,
|
||||
beginTotpLogin,
|
||||
verifyTotpLogin,
|
||||
completePin,
|
||||
applyLoginAuth,
|
||||
register,
|
||||
refreshProfile,
|
||||
logout
|
||||
|
||||
@@ -6,12 +6,13 @@ 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';
|
||||
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
|
||||
|
||||
interface PresignedUploadResponse {
|
||||
uploadUrl: string;
|
||||
storageKey: string;
|
||||
expiresAt: string;
|
||||
uploadToken?: string;
|
||||
}
|
||||
|
||||
export function AvatarUpload({
|
||||
@@ -53,15 +54,7 @@ export function AvatarUpload({
|
||||
token
|
||||
);
|
||||
|
||||
const uploadResponse = await fetch(presigned.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': file.type },
|
||||
body: file
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Не удалось загрузить файл в хранилище');
|
||||
}
|
||||
await uploadMediaObject(presigned, file, file.type);
|
||||
|
||||
await apiFetch('/media/avatars/confirm', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -28,6 +28,8 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
export { buttonVariants };
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
79
apps/frontend/components/ui/calendar.tsx
Normal file
79
apps/frontend/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { DayPicker, getDefaultClassNames, type DayButtonProps } from 'react-day-picker';
|
||||
import { ru } from 'react-day-picker/locale';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
function CalendarDayButton({ className, day, modifiers, ...props }: DayButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
|
||||
modifiers.selected && 'bg-[#111827] text-white hover:bg-[#111827] hover:text-white',
|
||||
modifiers.today && !modifiers.selected && 'bg-[#f4f5f8] text-[#111827]',
|
||||
modifiers.outside && 'text-[#a8adbc] opacity-50',
|
||||
modifiers.disabled && 'text-[#a8adbc] opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Calendar({ className, classNames, showOutsideDays = true, ...props }: React.ComponentProps<typeof DayPicker>) {
|
||||
const defaults = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
locale={ru}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
root: cn('rdp-root', defaults.root),
|
||||
months: cn('flex flex-col gap-4 sm:flex-row', defaults.months),
|
||||
month: cn('flex flex-col gap-4', defaults.month),
|
||||
month_caption: cn('flex justify-center pt-1 relative items-center', defaults.month_caption),
|
||||
caption_label: cn('text-sm font-medium capitalize', defaults.caption_label),
|
||||
dropdowns: cn('flex items-center justify-center gap-2', defaults.dropdowns),
|
||||
dropdown_root: cn('relative inline-flex items-center gap-1', defaults.dropdown_root),
|
||||
dropdown: defaults.dropdown,
|
||||
months_dropdown: defaults.months_dropdown,
|
||||
years_dropdown: defaults.years_dropdown,
|
||||
nav: cn('flex items-center gap-1', defaults.nav),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'absolute left-1 h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100',
|
||||
defaults.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'absolute right-1 h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100',
|
||||
defaults.button_next
|
||||
),
|
||||
month_grid: cn('w-full border-collapse space-y-1', defaults.month_grid),
|
||||
weekdays: cn('flex', defaults.weekdays),
|
||||
weekday: cn('text-[#667085] rounded-md w-9 font-normal text-[0.8rem]', defaults.weekday),
|
||||
week: cn('flex w-full mt-2', defaults.week),
|
||||
day: cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20', defaults.day),
|
||||
day_button: cn('h-9 w-9 p-0 font-normal aria-selected:opacity-100', defaults.day_button),
|
||||
selected: cn('bg-[#111827] text-white hover:bg-[#111827] hover:text-white focus:bg-[#111827] focus:text-white', defaults.selected),
|
||||
today: cn('bg-[#f4f5f8] text-[#111827]', defaults.today),
|
||||
outside: cn('text-[#a8adbc] opacity-50', defaults.outside),
|
||||
disabled: cn('text-[#a8adbc] opacity-50', defaults.disabled),
|
||||
hidden: cn('invisible', defaults.hidden),
|
||||
...classNames
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation }) => (orientation === 'left' ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />),
|
||||
DayButton: CalendarDayButton
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
59
apps/frontend/components/ui/date-picker.tsx
Normal file
59
apps/frontend/components/ui/date-picker.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Дата рождения',
|
||||
className,
|
||||
disabled
|
||||
}: {
|
||||
value?: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const selected = value ? parseISO(value) : undefined;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-11 w-full justify-start rounded-2xl border border-[#eceef4] bg-white px-4 text-left font-normal text-[#111827] hover:bg-white',
|
||||
!value && 'text-[#667085]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4 text-[#667085]" />
|
||||
{value ? format(parseISO(value), 'd MMMM yyyy', { locale: ru }) : placeholder}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(date) => onChange(date ? format(date, 'yyyy-MM-dd') : undefined)}
|
||||
disabled={(date) => date > new Date()}
|
||||
defaultMonth={selected ?? new Date(1990, 0, 1)}
|
||||
captionLayout="dropdown"
|
||||
hideNavigation
|
||||
startMonth={new Date(1920, 0)}
|
||||
endMonth={new Date()}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,35 @@
|
||||
import { useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const otpThemes = {
|
||||
dark: {
|
||||
box: 'border-[#555762] bg-transparent text-white focus:border-[#7b7f8f] focus:ring-white/10'
|
||||
},
|
||||
light: {
|
||||
box: 'border-[#eceef4] bg-white text-[#111827] focus:border-[#c7cad6] focus:ring-[#eceef4]'
|
||||
}
|
||||
} as const;
|
||||
|
||||
interface OtpInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onComplete?: (value: string) => void;
|
||||
length?: number;
|
||||
disabled?: boolean;
|
||||
variant?: keyof typeof otpThemes;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OtpInput({ value, onChange, onComplete, length = 6, disabled = false, className }: OtpInputProps) {
|
||||
export function OtpInput({
|
||||
value,
|
||||
onChange,
|
||||
onComplete,
|
||||
length = 6,
|
||||
disabled = false,
|
||||
variant = 'dark',
|
||||
className
|
||||
}: OtpInputProps) {
|
||||
const theme = otpThemes[variant];
|
||||
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const digits = Array.from({ length }, (_, index) => value[index] ?? '');
|
||||
|
||||
@@ -83,7 +102,10 @@ export function OtpInput({ value, onChange, onComplete, length = 6, disabled = f
|
||||
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"
|
||||
className={cn(
|
||||
'h-14 w-full rounded-2xl border text-center text-2xl font-semibold outline-none transition focus:ring-4 disabled:opacity-50',
|
||||
theme.box
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -65,34 +65,71 @@ export function toE164(country: CountryOption, digits: string) {
|
||||
return `${country.code}${digits.replace(/\D/g, '').slice(0, country.maxDigits)}`;
|
||||
}
|
||||
|
||||
export function parseE164Phone(e164: string): { country: CountryOption; digits: string } {
|
||||
if (!e164) {
|
||||
return { country: phoneCountries[0], digits: '' };
|
||||
}
|
||||
const sorted = [...phoneCountries].sort((a, b) => b.code.length - a.code.length);
|
||||
for (const country of sorted) {
|
||||
if (e164.startsWith(country.code)) {
|
||||
return { country, digits: e164.slice(country.code.length).replace(/\D/g, '') };
|
||||
}
|
||||
}
|
||||
return { country: phoneCountries[0], digits: e164.replace(/\D/g, '') };
|
||||
}
|
||||
|
||||
const phoneThemes = {
|
||||
dark: {
|
||||
shell: 'border-[#555762] bg-transparent text-white focus-within:border-[#7b7f8f] focus-within:ring-white/10',
|
||||
trigger: 'text-white hover:bg-[#2a2c36]',
|
||||
chevron: 'text-[#8f92a0]',
|
||||
input: 'text-white placeholder:text-[#8f92a0]',
|
||||
clear: 'text-[#8f92a0] hover:bg-[#2a2c36] hover:text-white'
|
||||
},
|
||||
light: {
|
||||
shell: 'border-[#eceef4] bg-white text-[#111827] focus-within:border-[#c7cad6] focus-within:ring-[#eceef4]',
|
||||
trigger: 'text-[#111827] hover:bg-[#f4f5f8]',
|
||||
chevron: 'text-[#667085]',
|
||||
input: 'text-[#111827] placeholder:text-[#667085]',
|
||||
clear: 'text-[#667085] hover:bg-[#f4f5f8] hover:text-[#111827]'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export function PhoneInput({
|
||||
country,
|
||||
value,
|
||||
onCountryChange,
|
||||
onValueChange,
|
||||
className
|
||||
className,
|
||||
variant = 'dark',
|
||||
required = true
|
||||
}: {
|
||||
country: CountryOption;
|
||||
value: string;
|
||||
onCountryChange: (country: CountryOption) => void;
|
||||
onValueChange: (digits: string) => void;
|
||||
className?: string;
|
||||
variant?: keyof typeof phoneThemes;
|
||||
required?: boolean;
|
||||
}) {
|
||||
const maskedValue = formatPhoneNumber(value, country);
|
||||
const theme = phoneThemes[variant];
|
||||
|
||||
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',
|
||||
'flex h-[58px] w-full items-center rounded-2xl border transition focus-within:ring-4',
|
||||
variant === 'light' ? 'h-11' : '',
|
||||
theme.shell,
|
||||
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="Выбрать страну">
|
||||
<button type="button" className={cn('flex h-full shrink-0 items-center gap-2 rounded-l-2xl px-4 outline-none', theme.trigger)} aria-label="Выбрать страну">
|
||||
<FlagIcon src={country.flagSrc} />
|
||||
<span className="text-base font-semibold">{country.code}</span>
|
||||
<ChevronDown className="h-4 w-4 text-[#8f92a0]" />
|
||||
<ChevronDown className={cn('h-4 w-4', theme.chevron)} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-1">
|
||||
@@ -122,16 +159,16 @@ export function PhoneInput({
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
className="h-full min-w-0 flex-1 bg-transparent px-1 text-lg text-white outline-none placeholder:text-[#8f92a0]"
|
||||
className={cn('h-full min-w-0 flex-1 bg-transparent px-1 text-lg outline-none', variant === 'light' ? 'text-base' : '', theme.input)}
|
||||
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
|
||||
required={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="Очистить телефон">
|
||||
<button type="button" className={cn('mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-full', theme.clear)} onClick={() => onValueChange('')} aria-label="Очистить телефон">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -129,14 +129,120 @@ export interface AuthTokens {
|
||||
pinVerified: boolean;
|
||||
user: PublicUser;
|
||||
sessionId: string;
|
||||
requiresTotp?: boolean;
|
||||
totpChallengeToken?: string;
|
||||
}
|
||||
|
||||
export interface UserProfileResponse {
|
||||
birthDate?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
backupEmail?: string | null;
|
||||
backupPhone?: string | null;
|
||||
displayName?: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
}
|
||||
|
||||
export interface QrLoginSession {
|
||||
sessionId: string;
|
||||
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
|
||||
expiresAt: string;
|
||||
qrPayload?: string;
|
||||
auth?: AuthTokens & { user?: PublicUser };
|
||||
}
|
||||
|
||||
export async function createQrLoginSession(deviceName: string) {
|
||||
return apiFetch<QrLoginSession>('/auth/advanced/qr/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
deviceName,
|
||||
fingerprint: getDeviceFingerprint(),
|
||||
deviceType: 'WEB'
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function pollQrLoginSession(sessionId: string) {
|
||||
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}`);
|
||||
}
|
||||
|
||||
export interface TotpSetupResponse {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
}
|
||||
|
||||
export interface TotpStatusResponse {
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface PresignedUploadResponse {
|
||||
uploadUrl: string;
|
||||
storageKey: string;
|
||||
expiresAt?: string;
|
||||
uploadToken?: string;
|
||||
}
|
||||
|
||||
export async function uploadMediaObject(
|
||||
presigned: PresignedUploadResponse,
|
||||
file: Blob,
|
||||
contentType: string
|
||||
) {
|
||||
if (presigned.uploadToken) {
|
||||
const apiBase = getApiUrl().replace(/\/$/, '');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file instanceof File ? file.name : 'upload.bin');
|
||||
const response = await fetch(`${apiBase}/media/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Upload-Token': presigned.uploadToken },
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить файл');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(presigned.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: file
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить файл');
|
||||
}
|
||||
}
|
||||
|
||||
export interface FamilyInviteCandidate {
|
||||
id: string;
|
||||
displayName: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
username?: string | null;
|
||||
hasAvatar?: boolean;
|
||||
}
|
||||
|
||||
export async function searchFamilyInviteUsers(groupId: string, query: string, token?: string | null) {
|
||||
return apiFetch<{ users?: FamilyInviteCandidate[] }>(
|
||||
`/family/groups/${groupId}/invite-search?q=${encodeURIComponent(query)}`,
|
||||
{},
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
export interface PasswordlessAuthResponse {
|
||||
requiresPassword: boolean;
|
||||
requiresTotp?: boolean;
|
||||
totpChallengeToken?: string;
|
||||
tempAuthToken?: string;
|
||||
auth?: AuthTokens;
|
||||
}
|
||||
|
||||
export interface OtpChannel {
|
||||
channel: 'email' | 'phone' | 'backupEmail' | 'backupPhone' | string;
|
||||
masked: string;
|
||||
}
|
||||
|
||||
export interface LoginMethod {
|
||||
kind: 'password' | 'otp';
|
||||
channel: string;
|
||||
@@ -147,9 +253,15 @@ export interface IdentifyResponse {
|
||||
exists: boolean;
|
||||
hasPassword: boolean;
|
||||
isPinEnabled: boolean;
|
||||
isTotpEnabled?: boolean;
|
||||
otpChannels?: OtpChannel[];
|
||||
methods?: LoginMethod[];
|
||||
}
|
||||
|
||||
export interface BeginTotpLoginResponse {
|
||||
totpChallengeToken: string;
|
||||
}
|
||||
|
||||
export interface OtpSendResponse {
|
||||
sent: boolean;
|
||||
expiresAt: string;
|
||||
@@ -587,8 +699,8 @@ export async function updateFamilyGroup(groupId: string, name: string, token?: s
|
||||
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 sendFamilyInvite(groupId: string, payload: { inviteeUserId: string; target?: string }, token?: string | null) {
|
||||
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token);
|
||||
}
|
||||
|
||||
export async function fetchFamilyInvites(token?: string | null) {
|
||||
|
||||
@@ -21,10 +21,13 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next": "^16.0.10",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
Reference in New Issue
Block a user