Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
import { useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Clock, Mail, Lock, User, Users } from 'lucide-react'
import { AuthLayout } from '@/components/auth/AuthLayout'
import { getRegistrationOptions, register } from '@/api/auth'
import { ApiError, errorDetail } from '@/api/client'
/** Текст предупреждения о несовпадении домена почты с эталонным. */
function domainMismatchMessage(domain: string): string {
return `Укажите рабочую почту — регистрация доступна только для домена @${domain}`
}
/**
* Экран регистрации (см. design/mockups/auth.html, блок «регистрация»).
* Поле «Команда» показывается только если выбор команды включён в
* настройках инстанса (`GET /auth/registration-options`, публичный эндпоинт).
* Поле «Рабочая почта» дополнительно сверяется с эталонным доменом
* (`registration-options.email_domain`), если в админке включена
* верификация по домену — проверка идёт по blur и при сабмите, backend
* при включённой верификации и чужом домене отвечает 400 `invalid_email_domain`.
* После успешной регистрации показывает состояние «подтвердите почту»
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
*/
export function RegisterPage() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [teamId, setTeamId] = useState('')
const [error, setError] = useState<string | null>(null)
const [emailDomainError, setEmailDomainError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null)
const { data: registrationOptions } = useQuery({
queryKey: ['auth', 'registration-options'],
queryFn: getRegistrationOptions,
})
const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false
const teams = registrationOptions?.teams ?? []
const expectedEmailDomain = registrationOptions?.email_domain ?? null
/** Домен после «@» не совпадает (без учёта регистра) с эталонным — `null`, если сверять не с чем. */
function checkEmailDomain(value: string): string | null {
if (!expectedEmailDomain) return null
const atIndex = value.lastIndexOf('@')
if (atIndex === -1) return null
const domain = value.slice(atIndex + 1).trim().toLowerCase()
if (domain !== expectedEmailDomain.toLowerCase()) {
return domainMismatchMessage(expectedEmailDomain)
}
return null
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
const domainError = checkEmailDomain(email)
setEmailDomainError(domainError)
if (domainError) return
setPending(true)
try {
await register({ email, name_user: name, password, team_id: teamChoiceEnabled ? teamId || null : null })
setRegisteredEmail(email)
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setError('Пользователь с таким email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') {
setEmailDomainError(expectedEmailDomain ? domainMismatchMessage(expectedEmailDomain) : 'Регистрация с этим доменом почты недоступна')
} else {
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
}
} finally {
setPending(false)
}
}
if (registeredEmail) {
return (
<AuthLayout>
<section className="screen-block">
<div className="confirm-card pending">
<div className="confirm-icon">
<Clock className="lucide" aria-hidden="true" />
</div>
<h3>Подтвердите почту</h3>
<p>
Мы отправили письмо со ссылкой на{' '}
<span className="mail-chip">
<Mail className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />
{registeredEmail}
</span>
</p>
<Link to="/login" className="btn btn-secondary btn-block btn-lg">
Перейти ко входу
</Link>
</div>
</section>
</AuthLayout>
)
}
return (
<AuthLayout>
<section className="screen-block">
<div className="tabs" role="tablist" aria-label="Способ авторизации">
<Link to="/login" className="tab" role="tab" aria-selected="false">
Вход
</Link>
<span className="tab is-active" role="tab" aria-selected="true">
Регистрация
</span>
</div>
<h2 className="screen-title">Создать аккаунт</h2>
<form onSubmit={handleSubmit} noValidate>
<div className="field">
<label htmlFor="reg-name">Имя и фамилия</label>
<div className="input-wrap">
<User className="lucide field-icon" aria-hidden="true" />
<input
id="reg-name"
type="text"
required
placeholder="Мария Кузнецова"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className={`field${emailDomainError ? ' has-error' : ''}`}>
<label htmlFor="reg-email">Рабочая почта</label>
<div className="input-wrap">
<Mail className="lucide field-icon" aria-hidden="true" />
<input
id="reg-email"
type="email"
autoComplete="email"
required
placeholder="you@company.ru"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={(e) => setEmailDomainError(checkEmailDomain(e.target.value))}
/>
</div>
{emailDomainError && <p className="error-msg">{emailDomainError}</p>}
</div>
{teamChoiceEnabled && (
<div className="field">
<label htmlFor="reg-team">Команда</label>
<div className="input-wrap">
<Users className="lucide field-icon" aria-hidden="true" />
<select id="reg-team" value={teamId} onChange={(e) => setTeamId(e.target.value)}>
<option value="">Иное</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</div>
</div>
)}
<div className={`field${error ? ' has-error' : ''}`}>
<label htmlFor="reg-pass">Пароль</label>
<div className="input-wrap">
<Lock className="lucide field-icon" aria-hidden="true" />
<input
id="reg-pass"
type="password"
autoComplete="new-password"
required
minLength={8}
placeholder="Минимум 8 символов"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error ? (
<p className="error-msg">{error}</p>
) : (
<p className="hint-msg">Минимум 8 символов, буквы и цифры</p>
)}
</div>
<button type="submit" className="btn btn-primary btn-block btn-lg" disabled={pending}>
{pending ? 'Регистрируем…' : 'Зарегистрироваться'}
</button>
</form>
</section>
</AuthLayout>
)
}