186 lines
6.7 KiB
JavaScript
186 lines
6.7 KiB
JavaScript
import { useState } from 'react';
|
||
import axios from 'axios';
|
||
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
||
import { AlertCircle, ArrowLeft, Loader2, Lock, User } from 'lucide-react';
|
||
import { useAuth } from '../contexts/AuthContext';
|
||
import BrandLogo from '../components/BrandLogo';
|
||
import passkeyIcon from '../assets/passkey.svg';
|
||
import devicesIllustration from '../assets/login-devices.svg';
|
||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||
|
||
const API_BASE_URL = getApiBaseUrl();
|
||
|
||
function getPasskeyErrorMessage(error) {
|
||
if (error?.name === 'NotAllowedError') {
|
||
return 'Запрос на использование passkey отменён или истёк.';
|
||
}
|
||
|
||
if (error?.name === 'InvalidStateError') {
|
||
return 'Этот passkey уже используется в другом запросе. Попробуйте ещё раз.';
|
||
}
|
||
|
||
return error?.response?.data?.error || error?.message || 'Не удалось выполнить вход по passkey.';
|
||
}
|
||
|
||
export default function LoginPage() {
|
||
const [username, setUsername] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const { login, loginWithPasskey } = useAuth();
|
||
const passkeysSupported = browserSupportsWebAuthn();
|
||
|
||
const handlePasswordLogin = async (event) => {
|
||
event.preventDefault();
|
||
setError('');
|
||
setLoading(true);
|
||
|
||
const result = await login(username, password);
|
||
|
||
if (!result.success) {
|
||
setError(result.error);
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handlePasskeyLogin = async () => {
|
||
if (!passkeysSupported) {
|
||
setError('Этот браузер не поддерживает вход по passkey. Используйте пароль.');
|
||
return;
|
||
}
|
||
|
||
setError('');
|
||
setLoading(true);
|
||
|
||
try {
|
||
const optionsResponse = await axios.post(`${API_BASE_URL}/auth/passkeys/authentication/options`);
|
||
const credential = await startAuthentication({ optionsJSON: optionsResponse.data });
|
||
const result = await loginWithPasskey(credential);
|
||
|
||
if (!result.success) {
|
||
setError(result.error);
|
||
setLoading(false);
|
||
}
|
||
} catch (passkeyError) {
|
||
setError(getPasskeyErrorMessage(passkeyError));
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const returnToLoginMethods = () => {
|
||
setShowPasswordForm(false);
|
||
setError('');
|
||
setPassword('');
|
||
};
|
||
|
||
return (
|
||
<main className="login-surface">
|
||
<div className="login-layout">
|
||
<section className="login-card" aria-labelledby="login-title">
|
||
<h1 className="login-brand">
|
||
<BrandLogo className="login-brand-logo" />
|
||
<span className="sr-only">RideBus DE</span>
|
||
</h1>
|
||
|
||
{error && (
|
||
<div className="login-error" role="alert">
|
||
<AlertCircle aria-hidden="true" />
|
||
<span>{error}</span>
|
||
</div>
|
||
)}
|
||
|
||
{showPasswordForm ? (
|
||
<form className="login-password-form" onSubmit={handlePasswordLogin}>
|
||
<div className="login-password-heading">
|
||
<button
|
||
className="login-back-button"
|
||
type="button"
|
||
onClick={returnToLoginMethods}
|
||
disabled={loading}
|
||
aria-label="Вернуться к способам входа"
|
||
>
|
||
<ArrowLeft aria-hidden="true" />
|
||
</button>
|
||
<h2 id="login-title">Войти с паролем</h2>
|
||
</div>
|
||
|
||
<label className="login-field">
|
||
<span>Имя пользователя</span>
|
||
<span className="login-input-wrap">
|
||
<User aria-hidden="true" />
|
||
<input
|
||
autoComplete="username"
|
||
type="text"
|
||
value={username}
|
||
onChange={(event) => setUsername(event.target.value)}
|
||
placeholder="admin"
|
||
required
|
||
autoFocus
|
||
disabled={loading}
|
||
/>
|
||
</span>
|
||
</label>
|
||
|
||
<label className="login-field">
|
||
<span>Пароль</span>
|
||
<span className="login-input-wrap">
|
||
<Lock aria-hidden="true" />
|
||
<input
|
||
autoComplete="current-password"
|
||
type="password"
|
||
value={password}
|
||
onChange={(event) => setPassword(event.target.value)}
|
||
placeholder="••••••••"
|
||
required
|
||
disabled={loading}
|
||
/>
|
||
</span>
|
||
</label>
|
||
|
||
<button className="login-password-submit" type="submit" disabled={loading}>
|
||
{loading ? <Loader2 className="animate-spin" aria-hidden="true" /> : null}
|
||
{loading ? 'Вход…' : 'Войти'}
|
||
</button>
|
||
</form>
|
||
) : (
|
||
<div className="login-methods" aria-labelledby="login-title">
|
||
<h2 id="login-title" className="sr-only">Вход в административную панель</h2>
|
||
<button
|
||
className="login-password-choice"
|
||
type="button"
|
||
onClick={() => setShowPasswordForm(true)}
|
||
disabled={loading}
|
||
>
|
||
Войти с паролем
|
||
</button>
|
||
|
||
<div className="login-divider" aria-hidden="true"><span>или</span></div>
|
||
|
||
<button
|
||
className="login-passkey-button"
|
||
type="button"
|
||
onClick={handlePasskeyLogin}
|
||
disabled={loading || !passkeysSupported}
|
||
title={passkeysSupported ? undefined : 'Passkey не поддерживается этим браузером'}
|
||
>
|
||
{loading ? <Loader2 className="animate-spin" aria-hidden="true" /> : <img src={passkeyIcon} alt="" />}
|
||
<span>{loading ? 'Подтверждение…' : 'Войти с Passkey'}</span>
|
||
</button>
|
||
|
||
<p className="login-passkey-hint">
|
||
Вход без пароля — с отпечатком пальца, Face ID или PIN-кодом устройства.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<footer className="login-footer">
|
||
<p>RideBus DE · Административная панель</p>
|
||
<p>Версия 1.0.0</p>
|
||
</footer>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|