global fix and add tauri app

This commit is contained in:
lendry
2026-06-26 13:56:54 +03:00
parent aa228d84eb
commit 3b05b7e4d4
50 changed files with 3947 additions and 80 deletions

32
tauri_app/src/App.tsx Normal file
View 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>
);
}

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,6 @@
export function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return fallback;
}

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