Files
IdP/tauri_app/src/features/auth/login-screen.tsx
2026-06-26 13:56:54 +03:00

160 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}