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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user