global fix and add tauri app
This commit is contained in:
32
tauri_app/src/App.tsx
Normal file
32
tauri_app/src/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { MobileShell, type MobileTab } from './components/mobile-shell';
|
||||
import { LoginScreen } from './features/auth/login-screen';
|
||||
import { ChatsScreen } from './features/chat/chats-screen';
|
||||
import { SecurityScreen } from './features/security/security-screen';
|
||||
import { TotpScreen } from './features/totp/totp-screen';
|
||||
import { MobileAuthProvider, useMobileAuth } from './lib/auth';
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated } = useMobileAuth();
|
||||
const [activeTab, setActiveTab] = useState<MobileTab>('security');
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell activeTab={activeTab} onTabChange={setActiveTab}>
|
||||
{activeTab === 'chats' ? <ChatsScreen /> : null}
|
||||
{activeTab === 'totp' ? <TotpScreen /> : null}
|
||||
{activeTab === 'security' ? <SecurityScreen /> : null}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<MobileAuthProvider>
|
||||
<AppContent />
|
||||
</MobileAuthProvider>
|
||||
);
|
||||
}
|
||||
76
tauri_app/src/components/mobile-shell.tsx
Normal file
76
tauri_app/src/components/mobile-shell.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { KeyRound, MessageCircle, ShieldCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type MobileTab = 'chats' | 'totp' | 'security';
|
||||
|
||||
const tabs: Array<{ id: MobileTab; label: string; icon: React.ComponentType<{ className?: string }> }> = [
|
||||
{ id: 'chats', label: 'Чаты', icon: MessageCircle },
|
||||
{ id: 'totp', label: 'Коды', icon: KeyRound },
|
||||
{ id: 'security', label: 'Защита', icon: ShieldCheck }
|
||||
];
|
||||
|
||||
export function MobileShell({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
children
|
||||
}: {
|
||||
activeTab: MobileTab;
|
||||
onTabChange: (tab: MobileTab) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#eef1f6] text-[#1f2430]">
|
||||
<div className="mx-auto flex min-h-screen max-w-6xl">
|
||||
<aside className="hidden w-72 border-r border-[#dce3ec] bg-white/80 p-4 backdrop-blur lg:block">
|
||||
<div className="mb-8 rounded-[24px] bg-[#20212b] p-5 text-white shadow-xl">
|
||||
<p className="text-sm text-white/70">Super App</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">Lendry ID</h1>
|
||||
</div>
|
||||
<nav className="space-y-2">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left text-sm font-medium transition',
|
||||
activeTab === tab.id ? 'bg-[#20212b] text-white' : 'text-[#667085] hover:bg-[#f4f5f8] hover:text-[#1f2430]'
|
||||
)}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1 pb-[88px] lg:pb-0">{children}</main>
|
||||
</div>
|
||||
|
||||
<nav className="safe-bottom fixed inset-x-0 bottom-0 z-50 border-t border-[#dce3ec] bg-white/95 px-4 py-2 shadow-[0_-12px_28px_rgba(31,36,48,0.12)] backdrop-blur lg:hidden">
|
||||
<div className="mx-auto grid max-w-md grid-cols-3 gap-2">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-2xl px-2 py-2 text-xs font-medium transition',
|
||||
activeTab === tab.id ? 'bg-[#20212b] text-white' : 'text-[#667085]'
|
||||
)}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
tauri_app/src/features/auth/login-screen.tsx
Normal file
159
tauri_app/src/features/auth/login-screen.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { KeyRound, Loader2, ShieldCheck } from 'lucide-react';
|
||||
import { BrandLogo, ProjectTagline } from '@/components/id/brand-logo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { beginTotpLogin, getMobileApiUrl, loginWithPassword, setMobileApiUrl, verifyTotpLogin } from '~/lib/mobile-api';
|
||||
import { useMobileAuth } from '~/lib/auth';
|
||||
import { getErrorMessage } from '~/lib/errors';
|
||||
|
||||
export function LoginScreen() {
|
||||
const { applyAuth } = useMobileAuth();
|
||||
const [apiUrl, setApiUrl] = useState(getMobileApiUrl());
|
||||
const [loginValue, setLoginValue] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [totpChallengeToken, setTotpChallengeToken] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const validated = await invoke<{ value: string }>('validate_api_base_url', { url: apiUrl });
|
||||
setMobileApiUrl(validated.value);
|
||||
const auth = await loginWithPassword(loginValue.trim(), password);
|
||||
if ('requiresTotp' in auth) {
|
||||
if (!auth.totpChallengeToken) {
|
||||
throw new Error('Сервер не вернул TOTP challenge');
|
||||
}
|
||||
setTotpChallengeToken(auth.totpChallengeToken);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
applyAuth(auth);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Не удалось войти'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTotpLogin() {
|
||||
if (!loginValue.trim()) {
|
||||
setError('Укажите почту, телефон или логин');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const validated = await invoke<{ value: string }>('validate_api_base_url', { url: apiUrl });
|
||||
setMobileApiUrl(validated.value);
|
||||
const response = await beginTotpLogin(loginValue.trim());
|
||||
setTotpChallengeToken(response.totpChallengeToken);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Не удалось начать TOTP-вход'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTotp() {
|
||||
if (!totpChallengeToken) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const auth = await verifyTotpLogin(totpChallengeToken, totpCode.trim());
|
||||
applyAuth(auth);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Неверный код аутентификатора'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#596075_0%,#2b2534_45%,#121017_100%)] p-4">
|
||||
<div className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-8 pb-7 pt-9 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>
|
||||
<p className="mt-2 text-sm text-[#b9bdc9]">Мессенджер, QR-вход и TOTP-коды в одном приложении</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[#b9bdc9]">API Gateway</label>
|
||||
<Input
|
||||
value={apiUrl}
|
||||
onChange={(event) => setApiUrl(event.target.value)}
|
||||
placeholder="https://sso.example.ru/idp-api"
|
||||
className="h-[54px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[#b9bdc9]">Почта, телефон или логин</label>
|
||||
<Input
|
||||
value={loginValue}
|
||||
onChange={(event) => setLoginValue(event.target.value)}
|
||||
autoComplete="username"
|
||||
className="h-[54px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
|
||||
/>
|
||||
</div>
|
||||
{totpChallengeToken ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[#b9bdc9]">Код из приложения-аутентификатора</label>
|
||||
<Input
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="h-[58px] border-[#555762] bg-transparent text-center text-2xl tracking-[0.35em] text-white placeholder:text-[#8f92a0]"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[#b9bdc9]">Пароль</label>
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="h-[54px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? <p className="mt-4 rounded-2xl bg-red-50 px-4 py-3 text-sm text-red-700">{error}</p> : null}
|
||||
|
||||
{totpChallengeToken ? (
|
||||
<>
|
||||
<Button variant="white" size="lg" className="mt-6 w-full rounded-[18px] text-base" disabled={submitting || totpCode.trim().length < 6} onClick={() => void submitTotp()}>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
|
||||
Подтвердить TOTP
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setTotpChallengeToken(null)}>
|
||||
Вернуться к паролю
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="white" size="lg" className="mt-6 w-full rounded-[18px] text-base" disabled={submitting || !loginValue.trim() || !password} onClick={() => void submit()}>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
||||
Войти
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" disabled={submitting || !loginValue.trim()} onClick={() => void startTotpLogin()}>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Войти через TOTP
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<ProjectTagline className="mt-8 text-center text-sm font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
tauri_app/src/features/auth/qr-scanner.tsx
Normal file
107
tauri_app/src/features/auth/qr-scanner.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Camera, CheckCircle2, Loader2, QrCode, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { approveQrLogin } from '~/lib/mobile-api';
|
||||
import { useMobileAuth } from '~/lib/auth';
|
||||
import { getErrorMessage } from '~/lib/errors';
|
||||
|
||||
export function QrScanner() {
|
||||
const { token } = useMobileAuth();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const controlsRef = useRef<IScannerControls | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [approving, setApproving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controlsRef.current?.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function approvePayload(payload: string) {
|
||||
if (!token || approving) return;
|
||||
setApproving(true);
|
||||
setStatus('Проверяем QR-код...');
|
||||
try {
|
||||
const validated = await invoke<{ value: string }>('validate_qr_session_payload', { payload });
|
||||
await approveQrLogin(validated.value, token);
|
||||
setStatus('Вход на сайте подтверждён');
|
||||
controlsRef.current?.stop();
|
||||
setScanning(false);
|
||||
} catch (error) {
|
||||
setStatus(getErrorMessage(error, 'Не удалось подтвердить вход'));
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
if (!videoRef.current) return;
|
||||
setStatus(null);
|
||||
setScanning(true);
|
||||
const reader = new BrowserMultiFormatReader();
|
||||
try {
|
||||
controlsRef.current = await reader.decodeFromVideoDevice(undefined, videoRef.current, (result, error) => {
|
||||
if (result?.getText()) {
|
||||
void approvePayload(result.getText());
|
||||
} else if (error) {
|
||||
setStatus('Камера активна, наведите её на QR-код');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus(getErrorMessage(error, 'Не удалось открыть камеру'));
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function stopScan() {
|
||||
controlsRef.current?.stop();
|
||||
setScanning(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] bg-white p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold">QR-вход</h2>
|
||||
<p className="text-sm text-[#667085]">Сканируйте QR-код на сайте, чтобы мгновенно подтвердить вход.</p>
|
||||
</div>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||||
<QrCode className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[24px] bg-[#111827]">
|
||||
<video ref={videoRef} className="aspect-[4/3] w-full object-cover" muted playsInline />
|
||||
</div>
|
||||
|
||||
{status ? (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-2xl bg-[#f4f5f8] px-3 py-2 text-sm text-[#667085]">
|
||||
{status.includes('подтвержд') ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <XCircle className="h-4 w-4 text-[#667085]" />}
|
||||
{status}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
{scanning ? (
|
||||
<Button variant="secondary" className="flex-1" onClick={stopScan}>
|
||||
Остановить
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex-1" onClick={() => void startScan()}>
|
||||
<Camera className="h-4 w-4" />
|
||||
Сканировать QR
|
||||
</Button>
|
||||
)}
|
||||
{approving ? (
|
||||
<Button variant="secondary" size="icon" disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
35
tauri_app/src/features/chat/chats-screen.tsx
Normal file
35
tauri_app/src/features/chat/chats-screen.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { ExternalLink, MessageCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const FRONTEND_URL = (import.meta.env.VITE_FRONTEND_URL ?? 'http://localhost:3002').replace(/\/$/, '');
|
||||
|
||||
export function ChatsScreen() {
|
||||
const familyUrl = `${FRONTEND_URL}/family`;
|
||||
|
||||
return (
|
||||
<div className="safe-top flex h-screen flex-col p-4">
|
||||
<header className="mb-4 rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-[#667085]">Messenger</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">Чаты</h1>
|
||||
<p className="mt-2 text-sm text-[#667085]">Вкладка использует web UI семьи, чтобы дизайн и поведение совпадали с сайтом.</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-[#20212b] text-white">
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[28px] bg-white shadow-sm">
|
||||
<iframe src={familyUrl} title="Lendry ID Family Messenger" className="h-full w-full border-0" />
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" className="mt-4 lg:hidden" onClick={() => void openUrl(familyUrl)}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Открыть мессенджер отдельно
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
tauri_app/src/features/security/security-screen.tsx
Normal file
116
tauri_app/src/features/security/security-screen.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, LogOut, RefreshCw, ShieldCheck, Smartphone } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ActiveSession } from '@/lib/api';
|
||||
import { useMobileAuth } from '~/lib/auth';
|
||||
import { fetchActiveSessions, revokeSession } from '~/lib/mobile-api';
|
||||
import { getErrorMessage } from '~/lib/errors';
|
||||
import { QrScanner } from '../auth/qr-scanner';
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function SecurityScreen() {
|
||||
const { user, token, sessionId, logout } = useMobileAuth();
|
||||
const [sessions, setSessions] = useState<ActiveSession[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadSessions() {
|
||||
if (!user || !token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetchActiveSessions(user.id, token);
|
||||
setSessions(response.sessions ?? []);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Не удалось загрузить сессии'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [token, user?.id]);
|
||||
|
||||
async function revoke(id: string) {
|
||||
if (!user || !token) return;
|
||||
setRevokingId(id);
|
||||
try {
|
||||
await revokeSession(user.id, id, token);
|
||||
setSessions((current) => current.filter((session) => session.id !== id));
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Не удалось отозвать сессию'));
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="safe-top mx-auto max-w-3xl space-y-4 p-4">
|
||||
<header className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-[#667085]">SSO Authenticator</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">Безопасность</h1>
|
||||
<p className="mt-2 text-sm text-[#667085]">{user?.displayName}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="icon" onClick={logout} aria-label="Выйти">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<QrScanner />
|
||||
|
||||
<section className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
Устройства и сессии
|
||||
</h2>
|
||||
<p className="text-sm text-[#667085]">Завершайте чужие или потерянные сессии удалённо.</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="icon" disabled={loading} onClick={() => void loadSessions()}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mb-3 rounded-2xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => {
|
||||
const current = session.id === sessionId;
|
||||
return (
|
||||
<div key={session.id} className="flex items-center gap-3 rounded-2xl border border-[#eceef4] p-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||||
<Smartphone className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{session.userAgent ?? 'Устройство'}</p>
|
||||
<p className="text-xs text-[#667085]">
|
||||
{session.status} · {formatDate(session.updatedAt)}
|
||||
{current ? ' · текущая сессия' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" disabled={current || revokingId === session.id} onClick={() => void revoke(session.id)}>
|
||||
{revokingId === session.id ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Выйти'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!sessions.length && !loading ? <p className="py-4 text-center text-sm text-[#667085]">Активных сессий не найдено</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
tauri_app/src/features/totp/secure-totp-store.ts
Normal file
85
tauri_app/src/features/totp/secure-totp-store.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { appDataDir } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { TotpProfile } from './totp';
|
||||
|
||||
const CLIENT_NAME = 'lendry-totp';
|
||||
const STORE_KEY = 'totp-profiles';
|
||||
const PASSWORD_KEY = 'lendry_stronghold_password';
|
||||
|
||||
function getStrongholdPassword() {
|
||||
const existing = window.localStorage.getItem(PASSWORD_KEY);
|
||||
if (existing) return existing;
|
||||
const created = crypto.randomUUID() + crypto.randomUUID();
|
||||
window.localStorage.setItem(PASSWORD_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function openStronghold() {
|
||||
const strongholdModule = await import('@tauri-apps/plugin-stronghold');
|
||||
const Stronghold = (strongholdModule as unknown as { Stronghold?: unknown }).Stronghold as {
|
||||
load: (path: string, password: string) => Promise<unknown>;
|
||||
};
|
||||
const baseDir = await appDataDir();
|
||||
const stronghold = await Stronghold.load(`${baseDir}/totp.stronghold`, getStrongholdPassword());
|
||||
const anyStronghold = stronghold as {
|
||||
loadClient?: (name: string) => Promise<unknown>;
|
||||
createClient?: (name: string) => Promise<unknown>;
|
||||
save: () => Promise<void>;
|
||||
};
|
||||
let client: unknown;
|
||||
try {
|
||||
client = await anyStronghold.loadClient?.(CLIENT_NAME);
|
||||
} catch {
|
||||
client = await anyStronghold.createClient?.(CLIENT_NAME);
|
||||
}
|
||||
if (!client) {
|
||||
client = await anyStronghold.createClient?.(CLIENT_NAME);
|
||||
}
|
||||
const store = (client as { getStore: () => unknown }).getStore();
|
||||
return { stronghold: anyStronghold, store: store as StrongholdStore };
|
||||
}
|
||||
|
||||
interface StrongholdStore {
|
||||
get(key: string): Promise<number[] | Uint8Array | null>;
|
||||
insert(key: string, value: number[] | Uint8Array): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
function encodeProfiles(profiles: TotpProfile[]) {
|
||||
return new TextEncoder().encode(JSON.stringify(profiles));
|
||||
}
|
||||
|
||||
function decodeProfiles(value: number[] | Uint8Array | null) {
|
||||
if (!value) return [];
|
||||
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
return JSON.parse(text) as TotpProfile[];
|
||||
}
|
||||
|
||||
export async function loadTotpProfiles() {
|
||||
const { store } = await openStronghold();
|
||||
return decodeProfiles(await store.get(STORE_KEY));
|
||||
}
|
||||
|
||||
export async function saveTotpProfiles(profiles: TotpProfile[]) {
|
||||
const { stronghold, store } = await openStronghold();
|
||||
await store.insert(STORE_KEY, encodeProfiles(profiles));
|
||||
await stronghold.save();
|
||||
}
|
||||
|
||||
export async function addTotpProfile(profile: Omit<TotpProfile, 'id' | 'createdAt'>) {
|
||||
await invoke('validate_totp_profile_input', profile);
|
||||
const profiles = await loadTotpProfiles();
|
||||
const next: TotpProfile = {
|
||||
...profile,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
await saveTotpProfiles([...profiles, next]);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function deleteTotpProfile(profileId: string) {
|
||||
const profiles = await loadTotpProfiles();
|
||||
await saveTotpProfiles(profiles.filter((profile) => profile.id !== profileId));
|
||||
}
|
||||
161
tauri_app/src/features/totp/totp-screen.tsx
Normal file
161
tauri_app/src/features/totp/totp-screen.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { addTotpProfile, deleteTotpProfile, loadTotpProfiles } from './secure-totp-store';
|
||||
import { generateTotpCode, getRemainingSeconds, parseOtpAuthUrl, type TotpProfile } from './totp';
|
||||
|
||||
interface TotpCodeState {
|
||||
code: string;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export function TotpScreen() {
|
||||
const [profiles, setProfiles] = useState<TotpProfile[]>([]);
|
||||
const [codes, setCodes] = useState<Record<string, TotpCodeState>>({});
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [otpauth, setOtpauth] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSave = useMemo(() => issuer.trim() && label.trim() && secret.trim(), [issuer, label, secret]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTotpProfiles()
|
||||
.then(setProfiles)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Не удалось открыть защищённое хранилище'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function tick() {
|
||||
const entries = await Promise.all(
|
||||
profiles.map(async (profile) => [
|
||||
profile.id,
|
||||
{
|
||||
code: await generateTotpCode(profile),
|
||||
remaining: getRemainingSeconds(profile.period)
|
||||
}
|
||||
] as const)
|
||||
);
|
||||
if (!cancelled) {
|
||||
setCodes(Object.fromEntries(entries));
|
||||
}
|
||||
}
|
||||
void tick();
|
||||
const timer = window.setInterval(() => void tick(), 1000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [profiles]);
|
||||
|
||||
function applyOtpAuthUrl(value: string) {
|
||||
setOtpauth(value);
|
||||
const parsed = parseOtpAuthUrl(value);
|
||||
if (!parsed) return;
|
||||
setIssuer(parsed.issuer ?? '');
|
||||
setLabel(parsed.label ?? '');
|
||||
setSecret(parsed.secret ?? '');
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const profile = await addTotpProfile({
|
||||
issuer: issuer.trim(),
|
||||
label: label.trim(),
|
||||
secret: secret.trim(),
|
||||
digits: 6,
|
||||
period: 30
|
||||
});
|
||||
setProfiles((current) => [...current, profile]);
|
||||
setIssuer('');
|
||||
setLabel('');
|
||||
setSecret('');
|
||||
setOtpauth('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось сохранить TOTP');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProfile(profileId: string) {
|
||||
await deleteTotpProfile(profileId);
|
||||
setProfiles((current) => current.filter((profile) => profile.id !== profileId));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="safe-top mx-auto max-w-3xl space-y-4 p-4">
|
||||
<header className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<p className="text-sm text-[#667085]">Offline 2FA</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">Authenticator</h1>
|
||||
<p className="mt-2 text-sm text-[#667085]">TOTP-секреты хранятся в Tauri Stronghold, а не в LocalStorage.</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 flex items-center gap-2 font-semibold">
|
||||
<Plus className="h-5 w-5" />
|
||||
Добавить код
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<Input value={otpauth} onChange={(event) => applyOtpAuthUrl(event.target.value)} placeholder="otpauth://totp/..." />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Input value={issuer} onChange={(event) => setIssuer(event.target.value)} placeholder="Сервис, например GitHub" />
|
||||
<Input value={label} onChange={(event) => setLabel(event.target.value)} placeholder="Аккаунт" />
|
||||
</div>
|
||||
<Input value={secret} onChange={(event) => setSecret(event.target.value)} placeholder="Base32 secret" />
|
||||
{error ? <p className="rounded-2xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
|
||||
<Button disabled={!canSave || saving} onClick={() => void saveProfile()}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
||||
Сохранить в защищённом хранилище
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="rounded-[28px] bg-white p-5 text-sm text-[#667085]">Загрузка...</div>
|
||||
) : profiles.length ? (
|
||||
profiles.map((profile) => {
|
||||
const state = codes[profile.id];
|
||||
const progress = state ? (state.remaining / profile.period) * 100 : 0;
|
||||
return (
|
||||
<article key={profile.id} className="rounded-[28px] bg-white p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{profile.issuer}</h3>
|
||||
<p className="text-sm text-[#667085]">{profile.label}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => void removeProfile(profile.id)} aria-label="Удалить TOTP">
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 flex w-full items-center justify-between rounded-3xl bg-[#20212b] px-5 py-4 text-left text-white"
|
||||
onClick={() => state?.code && navigator.clipboard.writeText(state.code)}
|
||||
>
|
||||
<span className="font-mono text-4xl font-semibold tracking-[0.2em]">{state?.code ?? '------'}</span>
|
||||
<Copy className="h-5 w-5 text-white/70" />
|
||||
</button>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#eef1f6]">
|
||||
<div className="h-full rounded-full bg-[#3390ec] transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-right text-xs text-[#667085]">{state?.remaining ?? profile.period} сек.</p>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-[28px] bg-white p-8 text-center text-sm text-[#667085] shadow-sm">Добавьте первый TOTP-код</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
tauri_app/src/features/totp/totp.ts
Normal file
81
tauri_app/src/features/totp/totp.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export interface TotpProfile {
|
||||
id: string;
|
||||
issuer: string;
|
||||
label: string;
|
||||
secret: string;
|
||||
digits: 6 | 8;
|
||||
period: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
export function normalizeBase32Secret(secret: string) {
|
||||
return secret.replace(/[\s-]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
export function parseOtpAuthUrl(value: string): Partial<TotpProfile> | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'otpauth:' || url.hostname !== 'totp') return null;
|
||||
const labelRaw = decodeURIComponent(url.pathname.replace(/^\//, ''));
|
||||
const [issuerFromLabel, accountFromLabel] = labelRaw.includes(':') ? labelRaw.split(/:(.*)/s) : ['', labelRaw];
|
||||
const secret = url.searchParams.get('secret');
|
||||
if (!secret) return null;
|
||||
const digits = Number(url.searchParams.get('digits') ?? 6);
|
||||
const period = Number(url.searchParams.get('period') ?? 30);
|
||||
return {
|
||||
issuer: url.searchParams.get('issuer') ?? issuerFromLabel ?? '',
|
||||
label: accountFromLabel || labelRaw,
|
||||
secret: normalizeBase32Secret(secret),
|
||||
digits: digits === 8 ? 8 : 6,
|
||||
period: Number.isFinite(period) && period > 0 ? period : 30
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeBase32(secret: string) {
|
||||
const normalized = normalizeBase32Secret(secret).replace(/=+$/g, '');
|
||||
let bits = '';
|
||||
for (const char of normalized) {
|
||||
const value = BASE32_ALPHABET.indexOf(char);
|
||||
if (value === -1) {
|
||||
throw new Error('Некорректный base32 secret');
|
||||
}
|
||||
bits += value.toString(2).padStart(5, '0');
|
||||
}
|
||||
|
||||
const bytes: number[] = [];
|
||||
for (let index = 0; index + 8 <= bits.length; index += 8) {
|
||||
bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function counterToBytes(counter: number) {
|
||||
const bytes = new ArrayBuffer(8);
|
||||
const view = new DataView(bytes);
|
||||
view.setUint32(4, counter, false);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function generateTotpCode(profile: Pick<TotpProfile, 'secret' | 'digits' | 'period'>, now = Date.now()) {
|
||||
const keyData = decodeBase32(profile.secret);
|
||||
const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
|
||||
const counter = Math.floor(now / 1000 / profile.period);
|
||||
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterToBytes(counter)));
|
||||
const offset = signature[signature.length - 1]! & 0x0f;
|
||||
const binary =
|
||||
((signature[offset]! & 0x7f) << 24) |
|
||||
((signature[offset + 1]! & 0xff) << 16) |
|
||||
((signature[offset + 2]! & 0xff) << 8) |
|
||||
(signature[offset + 3]! & 0xff);
|
||||
const mod = 10 ** profile.digits;
|
||||
return String(binary % mod).padStart(profile.digits, '0');
|
||||
}
|
||||
|
||||
export function getRemainingSeconds(period: number, now = Date.now()) {
|
||||
return period - (Math.floor(now / 1000) % period);
|
||||
}
|
||||
75
tauri_app/src/lib/auth.tsx
Normal file
75
tauri_app/src/lib/auth.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { AuthTokens, PublicUser } from '@/lib/api';
|
||||
import {
|
||||
clearMobileAuth,
|
||||
loginWithPassword,
|
||||
persistMobileAuth,
|
||||
readStoredMobileAuth
|
||||
} from './mobile-api';
|
||||
|
||||
interface MobileAuthContextValue {
|
||||
user: PublicUser | null;
|
||||
token: string | null;
|
||||
sessionId: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (login: string, password: string) => Promise<void>;
|
||||
applyAuth: (auth: AuthTokens) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const MobileAuthContext = createContext<MobileAuthContextValue | null>(null);
|
||||
|
||||
export function MobileAuthProvider({ children }: { children: ReactNode }) {
|
||||
const stored = typeof window === 'undefined' ? { token: null, sessionId: null, user: null } : readStoredMobileAuth();
|
||||
const [token, setToken] = useState<string | null>(stored.token);
|
||||
const [sessionId, setSessionId] = useState<string | null>(stored.sessionId);
|
||||
const [user, setUser] = useState<PublicUser | null>(stored.user);
|
||||
|
||||
const applyAuth = useCallback((auth: AuthTokens) => {
|
||||
persistMobileAuth(auth);
|
||||
setToken(auth.accessToken);
|
||||
setSessionId(auth.sessionId);
|
||||
setUser(auth.user);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (loginValue: string, password: string) => {
|
||||
const auth = await loginWithPassword(loginValue, password);
|
||||
if ('requiresTotp' in auth) {
|
||||
throw new Error('Для этого аккаунта требуется TOTP-код');
|
||||
}
|
||||
applyAuth(auth);
|
||||
},
|
||||
[applyAuth]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearMobileAuth();
|
||||
setToken(null);
|
||||
setSessionId(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
token,
|
||||
sessionId,
|
||||
isAuthenticated: Boolean(user && token),
|
||||
login,
|
||||
applyAuth,
|
||||
logout
|
||||
}),
|
||||
[applyAuth, login, logout, sessionId, token, user]
|
||||
);
|
||||
|
||||
return <MobileAuthContext.Provider value={value}>{children}</MobileAuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useMobileAuth() {
|
||||
const value = useContext(MobileAuthContext);
|
||||
if (!value) {
|
||||
throw new Error('useMobileAuth должен использоваться внутри MobileAuthProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
6
tauri_app/src/lib/errors.ts
Normal file
6
tauri_app/src/lib/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
138
tauri_app/src/lib/mobile-api.ts
Normal file
138
tauri_app/src/lib/mobile-api.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { ActiveSession, AuthTokens, PublicUser } from '@/lib/api';
|
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:3000';
|
||||
|
||||
export const MOBILE_AUTH_TOKEN_KEY = 'lendry_mobile_access_token';
|
||||
export const MOBILE_REFRESH_TOKEN_KEY = 'lendry_mobile_refresh_token';
|
||||
export const MOBILE_SESSION_ID_KEY = 'lendry_mobile_session_id';
|
||||
export const MOBILE_USER_KEY = 'lendry_mobile_user';
|
||||
export const MOBILE_API_URL_KEY = 'lendry_mobile_api_url';
|
||||
|
||||
export class MobileApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'MobileApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export function getMobileApiUrl() {
|
||||
return (window.localStorage.getItem(MOBILE_API_URL_KEY) ?? import.meta.env.VITE_API_URL ?? DEFAULT_API_URL).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function setMobileApiUrl(url: string) {
|
||||
window.localStorage.setItem(MOBILE_API_URL_KEY, url.replace(/\/$/, ''));
|
||||
}
|
||||
|
||||
export function readStoredMobileAuth() {
|
||||
const token = window.localStorage.getItem(MOBILE_AUTH_TOKEN_KEY);
|
||||
const refreshToken = window.localStorage.getItem(MOBILE_REFRESH_TOKEN_KEY);
|
||||
const sessionId = window.localStorage.getItem(MOBILE_SESSION_ID_KEY);
|
||||
const rawUser = window.localStorage.getItem(MOBILE_USER_KEY);
|
||||
let user: PublicUser | null = null;
|
||||
if (rawUser) {
|
||||
try {
|
||||
user = JSON.parse(rawUser) as PublicUser;
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
return { token, refreshToken, sessionId, user };
|
||||
}
|
||||
|
||||
export function persistMobileAuth(auth: AuthTokens) {
|
||||
window.localStorage.setItem(MOBILE_AUTH_TOKEN_KEY, auth.accessToken);
|
||||
window.localStorage.setItem(MOBILE_REFRESH_TOKEN_KEY, auth.refreshToken);
|
||||
window.localStorage.setItem(MOBILE_SESSION_ID_KEY, auth.sessionId);
|
||||
window.localStorage.setItem(MOBILE_USER_KEY, JSON.stringify(auth.user));
|
||||
}
|
||||
|
||||
export function clearMobileAuth() {
|
||||
window.localStorage.removeItem(MOBILE_AUTH_TOKEN_KEY);
|
||||
window.localStorage.removeItem(MOBILE_REFRESH_TOKEN_KEY);
|
||||
window.localStorage.removeItem(MOBILE_SESSION_ID_KEY);
|
||||
window.localStorage.removeItem(MOBILE_USER_KEY);
|
||||
}
|
||||
|
||||
async function parseError(response: Response) {
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string | string[]; error?: string; code?: string };
|
||||
const message = Array.isArray(body.message) ? body.message.join('\n') : body.message ?? body.error ?? 'Ошибка запроса';
|
||||
return new MobileApiError(message, response.status, body.code);
|
||||
} catch {
|
||||
return new MobileApiError('Ошибка запроса', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mobileFetch<T>(path: string, options: RequestInit = {}, token?: string | null): Promise<T> {
|
||||
const response = await fetch(`${getMobileApiUrl()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseError(response);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export interface TotpChallengeResponse {
|
||||
totpChallengeToken: string;
|
||||
}
|
||||
|
||||
export type PasswordLoginResponse = AuthTokens | {
|
||||
requiresTotp: true;
|
||||
totpChallengeToken: string;
|
||||
};
|
||||
|
||||
export async function loginWithPassword(login: string, password: string) {
|
||||
return mobileFetch<PasswordLoginResponse>('/auth/login/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
login,
|
||||
password,
|
||||
fingerprint: crypto.randomUUID(),
|
||||
deviceName: 'Lendry ID Mobile',
|
||||
deviceType: 'MOBILE'
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function beginTotpLogin(recipient: string) {
|
||||
return mobileFetch<TotpChallengeResponse>('/auth/totp/begin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipient,
|
||||
fingerprint: crypto.randomUUID(),
|
||||
deviceName: 'Lendry ID Mobile',
|
||||
deviceType: 'MOBILE'
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyTotpLogin(totpChallengeToken: string, code: string) {
|
||||
return mobileFetch<AuthTokens>('/auth/totp/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ totpChallengeToken, code })
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveQrLogin(sessionId: string, token: string) {
|
||||
return mobileFetch(`/auth/advanced/qr/session/${encodeURIComponent(sessionId)}/approve`, { method: 'POST' }, token);
|
||||
}
|
||||
|
||||
export async function fetchActiveSessions(userId: string, token: string) {
|
||||
return mobileFetch<{ sessions?: ActiveSession[] }>(`/security/users/${encodeURIComponent(userId)}/sessions`, {}, token);
|
||||
}
|
||||
|
||||
export async function revokeSession(userId: string, sessionId: string, token: string) {
|
||||
return mobileFetch(`/security/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/revoke`, { method: 'POST' }, token);
|
||||
}
|
||||
10
tauri_app/src/main.tsx
Normal file
10
tauri_app/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
21
tauri_app/src/styles.css
Normal file
21
tauri_app/src/styles.css
Normal file
@@ -0,0 +1,21 @@
|
||||
@import "tailwindcss";
|
||||
@import "../../apps/frontend/app/globals.css";
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
Reference in New Issue
Block a user