Files
IdP/apps/frontend/app/auth/login/page.tsx
2026-06-24 14:37:15 +03:00

366 lines
15 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.
'use client';
import Link from 'next/link';
import { FormEvent, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronLeft, KeyRound, Mail, Network, Phone, ShieldQuestion, UserRound } from 'lucide-react';
import { BrandLogo, ProjectTagline } from '@/components/id/brand-logo';
import { useAuth } from '@/components/id/auth-provider';
import { usePublicSettings } from '@/components/id/public-settings-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { OtpInput } from '@/components/ui/otp-input';
import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { LoginMethod } from '@/lib/api';
type Step = 'identify' | 'otp' | 'password' | 'pin' | 'ldap';
const channelIcon: Record<string, typeof Mail> = {
email: Mail,
backupEmail: Mail,
phone: Phone,
backupPhone: Phone,
password: KeyRound
};
export default function LoginPage() {
const router = useRouter();
const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, completePin } = useAuth();
const { showToast } = useToast();
const { ldapEnabled, ldapUseLdaps } = usePublicSettings();
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
const [email, setEmail] = useState('');
const [phoneNumber, setPhoneNumber] = useState('');
const [country, setCountry] = useState(phoneCountries[0]);
const [recipient, setRecipient] = useState('');
const [maskedTarget, setMaskedTarget] = useState('');
const [otp, setOtp] = useState('');
const [password, setPassword] = useState('');
const [ldapUsername, setLdapUsername] = useState('');
const [ldapPassword, setLdapPassword] = useState('');
const [pin, setPin] = useState('');
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [methods, setMethods] = useState<LoginMethod[]>([]);
const [showMethods, setShowMethods] = useState(false);
const [step, setStep] = useState<Step>('identify');
const [isSubmitting, setIsSubmitting] = useState(false);
function getIdentifier() {
return authTab === 'email' ? email.trim() : toE164(country, phoneNumber);
}
function finishAuth(pinVerified: boolean, sessionId: string) {
if (!pinVerified) {
setPendingSessionId(sessionId);
setStep('pin');
} else {
router.push('/');
}
}
async function handleIdentify(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
try {
const identifier = getIdentifier();
setRecipient(identifier);
const result = await identifyLogin(identifier);
setMethods(result.methods ?? []);
const masked = await sendLoginOtp(identifier);
setMaskedTarget(masked);
setOtp('');
setStep('otp');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось продолжить вход');
} finally {
setIsSubmitting(false);
}
}
async function verifyOtp(code: string) {
setIsSubmitting(true);
try {
const response = await verifyLoginOtp(recipient, code);
if (response.auth) {
finishAuth(response.auth.pinVerified, response.auth.sessionId);
} else {
showToast('Не удалось завершить вход');
}
} catch (error) {
setOtp('');
showToast(error instanceof Error ? error.message : 'Неверный код');
} finally {
setIsSubmitting(false);
}
}
async function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
try {
const auth = await loginWithPassword(recipient, password);
finishAuth(auth.pinVerified, auth.sessionId);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Неверный пароль');
} finally {
setIsSubmitting(false);
}
}
async function chooseMethod(method: LoginMethod) {
setShowMethods(false);
if (method.kind === 'password') {
setPassword('');
setStep('password');
return;
}
setIsSubmitting(true);
try {
const masked = await sendLoginOtp(recipient, method.channel);
setMaskedTarget(masked);
setOtp('');
setStep('otp');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
} finally {
setIsSubmitting(false);
}
}
async function handleLdapSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
try {
const auth = await loginWithLdap(ldapUsername.trim(), ldapPassword);
finishAuth(auth.pinVerified, auth.sessionId);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось выполнить LDAP-вход');
} finally {
setIsSubmitting(false);
}
}
async function handlePinSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!pendingSessionId) return;
setIsSubmitting(true);
try {
await completePin(pendingSessionId, pin);
router.push('/');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось проверить PIN-код');
} finally {
setIsSubmitting(false);
}
}
function resetToIdentify() {
setStep('identify');
setShowMethods(false);
setOtp('');
setPassword('');
setLdapUsername('');
setLdapPassword('');
}
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#596075_0%,#2b2534_45%,#121017_100%)] px-5">
<section className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-9 pb-8 pt-10 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>
</div>
{step !== 'identify' ? (
<button type="button" onClick={resetToIdentify} className="mb-4 flex items-center gap-1 text-sm text-[#b9bdc9] hover:text-white">
<ChevronLeft className="h-4 w-4" />
Изменить аккаунт
</button>
) : null}
{step === 'identify' ? (
<form onSubmit={handleIdentify}>
<Tabs value={authTab} onValueChange={(value) => setAuthTab(value as 'email' | 'phone')} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="email">Почта</TabsTrigger>
<TabsTrigger value="phone">Телефон</TabsTrigger>
</TabsList>
<TabsContent value="email">
<Input
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
placeholder="name@example.com"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required={authTab === 'email'}
/>
</TabsContent>
<TabsContent value="phone">
<PhoneInput country={country} value={phoneNumber} onCountryChange={setCountry} onValueChange={setPhoneNumber} />
</TabsContent>
</Tabs>
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
{isSubmitting ? 'Отправляем код...' : 'Далее'}
</Button>
{ldapEnabled ? (
<Button
type="button"
variant="outline"
size="lg"
className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]"
onClick={() => {
setLdapUsername('');
setLdapPassword('');
setStep('ldap');
}}
>
<Network className="h-5 w-5" />
{ldapUseLdaps ? 'Войти через LDAPS' : 'Войти через LDAP'}
</Button>
) : null}
</form>
) : null}
{step === 'ldap' ? (
<form onSubmit={handleLdapSubmit}>
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
<Network className="h-5 w-5" />
</div>
<div className="min-w-0 text-left">
<p className="text-sm text-[#b9bdc9]">Корпоративный вход</p>
<p className="truncate text-sm font-semibold">{ldapUseLdaps ? 'LDAPS' : 'LDAP'}</p>
</div>
</div>
<Input
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
placeholder="Логин LDAP"
value={ldapUsername}
onChange={(event) => setLdapUsername(event.target.value)}
autoComplete="username"
required
/>
<Input
className="mt-3 h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
placeholder="Пароль LDAP"
type="password"
value={ldapPassword}
onChange={(event) => setLdapPassword(event.target.value)}
autoComplete="current-password"
required
/>
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
{isSubmitting ? 'Проверяем...' : ldapUseLdaps ? 'Войти через LDAPS' : 'Войти через LDAP'}
</Button>
</form>
) : null}
{step === 'otp' ? (
<div>
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
<UserRound className="h-5 w-5" />
</div>
<div className="min-w-0 text-left">
<p className="text-sm text-[#b9bdc9]">Код отправлен на</p>
<p className="truncate text-sm font-semibold">{maskedTarget || recipient}</p>
</div>
</div>
<OtpInput value={otp} onChange={setOtp} onComplete={verifyOtp} disabled={isSubmitting} />
{isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Проверяем код...</p> : null}
{methods.length ? (
<div className="mt-6">
<Button type="button" variant="outline" size="lg" className="w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setShowMethods((prev) => !prev)}>
<ShieldQuestion className="h-5 w-5" />
Другой способ входа
</Button>
{showMethods ? (
<div className="mt-3 space-y-2">
{methods.map((method) => {
const Icon = channelIcon[method.channel] ?? KeyRound;
return (
<button
key={`${method.kind}-${method.channel}`}
type="button"
onClick={() => chooseMethod(method)}
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f]"
>
<Icon className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm">
{method.kind === 'password' ? 'Войти с паролем' : `Код на ${method.masked}`}
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
</div>
) : null}
{step === 'password' ? (
<form onSubmit={handlePasswordSubmit}>
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
<UserRound className="h-5 w-5" />
</div>
<div className="min-w-0 text-left">
<p className="text-sm text-[#b9bdc9]">Вход в аккаунт</p>
<p className="truncate text-sm font-semibold">{recipient}</p>
</div>
</div>
<Input
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
placeholder="Пароль"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
{isSubmitting ? 'Входим...' : 'Войти'}
</Button>
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setStep('otp')}>
Войти по коду
</Button>
</form>
) : null}
{step === 'pin' && pendingSessionId ? (
<form onSubmit={handlePinSubmit} className="space-y-3">
<Input
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
placeholder="PIN"
value={pin}
onChange={(event) => setPin(event.target.value)}
required
minLength={4}
maxLength={6}
/>
<Button variant="white" size="lg" className="w-full rounded-[18px] text-base" disabled={isSubmitting}>
{isSubmitting ? 'Проверяем...' : 'Подтвердить PIN'}
</Button>
</form>
) : null}
{step === 'identify' ? (
<div className="mt-3">
<Button asChild variant="outline" size="lg" className="w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]">
<Link href="/auth/register">Создать ID</Link>
</Button>
</div>
) : null}
<ProjectTagline className="mt-9 text-center text-sm font-semibold" />
<p className="mt-2 text-center text-sm font-bold">Узнать больше</p>
</section>
</main>
);
}