Add SVG assets for login devices and passkey icons

- Created a new SVG file for login devices with detailed paths and styles.
- Added a new SVG file for the passkey icon with multiple vector paths and fill attributes.
This commit is contained in:
2026-09-17 20:31:10 +03:00
parent acec6b4fc0
commit f218f0123a
22 changed files with 9839 additions and 169 deletions

View File

@@ -1,17 +1,38 @@
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 { Lock, User, AlertCircle, Loader2 } from 'lucide-react';
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 } = useAuth();
const { login, loginWithPasskey } = useAuth();
const passkeysSupported = browserSupportsWebAuthn();
const handleSubmit = async (e) => {
e.preventDefault();
const handlePasswordLogin = async (event) => {
event.preventDefault();
setError('');
setLoading(true);
@@ -23,105 +44,147 @@ export default function LoginPage() {
}
};
return (
<div className="login-surface">
<div className="w-full max-w-md relative">
{/* Logo and Title */}
<div className="text-center mb-8 animate-[fadeSlideIn_0.6s_ease-out]">
<h1 className="mb-4"><BrandLogo className="mx-auto" /><span className="sr-only">Polotsk Transit</span></h1>
<p className="text-gray-600">Административная панель</p>
</div>
const handlePasskeyLogin = async () => {
if (!passkeysSupported) {
setError('Этот браузер не поддерживает вход по passkey. Используйте пароль.');
return;
}
{/* Login Card */}
<div className="login-card">
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">Вход в систему</h2>
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="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 animate-[shake_0.4s_ease-out]">
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-900">Ошибка входа</p>
<p className="text-sm text-red-700">{error}</p>
</div>
<div className="login-error" role="alert">
<AlertCircle aria-hidden="true" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Username */}
<div>
<label htmlFor="username" className="label">Имя пользователя</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
id="username"
autoComplete="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input pl-10"
placeholder="admin"
required
autoFocus
{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>
{/* Password */}
<div>
<label htmlFor="password" className="label">Пароль</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
id="password"
autoComplete="current-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input pl-10"
placeholder="••••••••"
required
disabled={loading}
/>
</div>
</div>
<aside className="login-device-card">
<img src={devicesIllustration} alt="" />
<p>Вход в служебных приложениях.</p>
</aside>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full btn-primary py-3 text-base font-semibold relative overflow-hidden group"
>
{loading ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin inline" />
Вход...
</>
) : (
<>
<span className="relative z-10">Войти</span>
<div className="absolute inset-0 bg-gradient-to-r from-primary-700 to-primary-600 transform scale-x-0 group-hover:scale-x-100 transition-transform origin-left"></div>
</>
)}
</button>
</form>
{/* Help text */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Мастер-аккаунт по умолчанию:
</p>
<p className="text-xs text-gray-500 mt-1">
<code className="bg-gray-100 px-2 py-1 rounded">admin / ADMIN_PASSWORD</code>
</p>
</div>
</div>
{/* Footer */}
<p className="text-center text-sm text-gray-500 mt-6 animate-[fadeIn_0.6s_ease-out_0.4s_backwards]">
© 2024 Polotsk Transit API • v1.0.0
</p>
<footer className="login-footer">
<p>RideBus DE · Административная панель</p>
<p>Версия 1.0.0</p>
</footer>
</div>
</div>
</main>
);
}