Files
ridebus-admin/frontend/src/pages/LoginPage.jsx

186 lines
6.7 KiB
JavaScript
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 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>
);
}