Files
glchat/web/src/pages/LoginPage.tsx
T
grendervill cebce0ae3b feat(auth): вход через OAuth-провайдеры (GitHub, Google, Discord)
Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения
OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по
подтверждённому провайдером email через blind index, вход забаненному на
инстансе запрещён, все входы и привязки попадают в события безопасности,
привязка — ещё и в аудит инстанса.

Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256,
токены провайдеров не сохраняются вовсе; state подписывается ключом сессий
и ограничен по времени, редирект после входа — только внутренний путь.
Если провайдер не настроен, ручка отвечает oauth.provider_not_configured,
а клиент показывает человеческий текст вместо кнопки.

Клиент: кнопки входа по списку включённых провайдеров на экране входа и
раздел «Вход через внешние сервисы» в настройках безопасности; адрес
возврата для настроек приложения виден в GET /auth/oauth/providers.

Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example
знают про OAUTH_* и сохраняют значения при --reconfigure.

Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой
провайдер), создание и повторный вход, привязка к существующему аккаунту,
неподтверждённый email, выключенная регистрация, бан инстанса, mocked
провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
2026-09-26 15:13:24 +03:00

252 lines
9.9 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.
import { useState, type FormEvent } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { login } from '@/api/auth';
import { fetchMeta, metaQueryKey } from '@/api/meta';
import { fetchOAuthProviders, oauthStartURL } from '@/api/oauth';
import { loginWithPasskey } from '@/api/passkeys';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { getQueryClient } from '@/lib/queryClient';
import { useInstance } from '@/lib/hooks';
import { errorCode } from '@/lib/format';
import { isPasskeySupported, WebAuthnClientError } from '@/lib/webauthn';
import { useGatewayStore } from '@/stores/gateway';
interface LocationState {
from?: string;
}
/**
* Форма входа. Поле кода 2FA показывается всегда: сервер принимает и код из
* приложения (6 цифр), и резервный код вида `a8eh-pshp-t8st`, а при попытке
* входа без кода отвечает `auth.2fa_required` — тогда подсказываем про код.
*
* Дополнительно (Фаза 7): вход по ключу доступа (passkeys) и кнопки внешних
* провайдеров, включённых на инстансе.
*/
export default function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const instance = useInstance();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [needsTotp, setNeedsTotp] = useState(false);
const [searchParams] = useSearchParams();
// Возврат после входа: `?next=` (страница приглашения) важнее состояния
// перехода из AuthGuard, но только для внутренних путей.
const next = searchParams.get('next');
const stateFrom = (location.state as LocationState | null)?.from ?? null;
const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app');
const oauthReturned = searchParams.get('oauth');
const oauthError = searchParams.get('oauth_error');
const providers = useQuery({
queryKey: ['auth', 'oauth', 'providers'],
queryFn: ({ signal }) => fetchOAuthProviders(signal),
staleTime: 5 * 60 * 1000,
});
// Признак доступности passkeys берём из меты инстанса: на IP-адресе (стенд
// без домена) WebAuthn невозможен, и кнопку показывать нельзя.
const meta = useQuery({
queryKey: metaQueryKey,
queryFn: ({ signal }) => fetchMeta(signal),
staleTime: 60_000,
});
const passkeySupported = isPasskeySupported() && meta.data?.features.passkeys_enabled === true;
const finishLogin = async (): Promise<void> => {
// Профиль перечитываем заново: cookie уже выставлена сервером. Признак
// отозванной сессии снимаем — иначе AuthGuard увёл бы обратно на /login.
useGatewayStore.getState().reset();
await getQueryClient().invalidateQueries();
void navigate(from === '/login' ? '/app' : from, { replace: true });
};
const submit = useMutation({
mutationFn: () => {
// Пустое поле — вход без кода: сервер сам скажет, нужен ли он.
const code = totpCode.trim();
return login(code === '' ? { email, password } : { email, password, totp_code: code });
},
onSuccess: finishLogin,
onError: (error: unknown) => {
if (errorCode(error) === 'auth.2fa_required') {
// Логин, пароль и уже введённый код остаются в форме.
setNeedsTotp(true);
}
},
});
const passkey = useMutation({
mutationFn: () => loginWithPasskey(email.trim() === '' ? undefined : email.trim()),
onSuccess: finishLogin,
});
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setNeedsTotp(false);
submit.mutate();
};
const registrationEnabled = instance.data?.registration_enabled === true;
const providerList = providers.data?.providers ?? [];
// Ошибка возврата от провайдера: показываем человеческий текст.
const oauthErrorMessage =
oauthError === null
? null
: t(
oauthError === 'provider_not_configured'
? 'auth.login.oauthNotConfigured'
: oauthError === 'email_unverified'
? 'auth.login.oauthEmailUnverified'
: oauthError === 'email_missing'
? 'auth.login.oauthEmailMissing'
: oauthError === 'user.banned'
? 'errors.user.banned'
: 'auth.login.oauthFailed',
);
// Ошибка клиента WebAuthn важнее серверной: она объясняет, что делать.
const passkeyErrorKey =
passkey.error instanceof WebAuthnClientError && passkey.error.code === 'cancelled'
? 'auth.login.passkeyCancelled'
: passkey.error instanceof WebAuthnClientError && passkey.error.code === 'unsupported'
? 'auth.login.passkeyUnsupported'
: null;
return (
<main className="gl-page mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
<header>
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.login.title')}</h1>
<p className="mt-1 text-fg-muted">{t('auth.login.subtitle')}</p>
</header>
<Card>
{oauthReturned === 'ok' ? (
<p className="mb-4 text-sm text-success" role="status">
{t('auth.login.oauthDone')}
</p>
) : null}
{oauthErrorMessage === null ? null : (
<p className="mb-4 text-sm text-danger" role="alert" data-testid="oauth-error">
{oauthErrorMessage}
</p>
)}
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('auth.login.email')}
type="email"
name="email"
autoComplete="username"
placeholder={t('auth.login.emailPlaceholder')}
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<Field
label={t('auth.login.password')}
type="password"
name="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<Field
label={t('auth.login.totp')}
hint={t('auth.login.totpHint')}
name="totp_code"
inputMode="text"
autoComplete="one-time-code"
maxLength={32}
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
/>
{needsTotp ? (
<p role="alert" className="text-sm text-danger">
{t('auth.login.totpRequired')}
</p>
) : null}
{/* При `auth.2fa_required` подсказка уже показана у поля — не дублируем. */}
{submit.isError && !needsTotp ? <ErrorNotice error={submit.error} /> : null}
<Button type="submit" disabled={submit.isPending}>
{t(submit.isPending ? 'auth.login.submitting' : 'auth.login.submit')}
</Button>
</form>
{passkeySupported ? (
<div className="mt-4 flex flex-col gap-2">
<Button
variant="ghost"
data-testid="passkey-login"
disabled={passkey.isPending}
onClick={() => {
setNeedsTotp(false);
passkey.mutate();
}}
>
{t(passkey.isPending ? 'auth.login.passkeyPending' : 'auth.login.passkeySubmit')}
</Button>
{passkeyErrorKey === null ? (
<ErrorNotice error={passkey.error} />
) : (
<p className="text-sm text-danger" role="alert" data-testid="passkey-error">
{t(passkeyErrorKey)}
</p>
)}
</div>
) : null}
{providerList.length === 0 ? null : (
<div className="mt-4 flex flex-col gap-2 border-t border-border/60 pt-4">
<p className="text-xs text-fg-muted">{t('auth.login.oauthHint')}</p>
{providerList.map((provider) => (
<Button
key={provider.id}
variant="ghost"
data-testid={`oauth-${provider.id}`}
onClick={() => {
window.location.assign(oauthStartURL(provider.id, from));
}}
>
{t('auth.login.oauthSubmit', { provider: provider.name })}
</Button>
))}
</div>
)}
<p className="mt-4 text-sm text-fg-muted">
{t('auth.login.noAccount')}{' '}
{registrationEnabled ? (
<Link className="text-accent underline" to="/register">
{t('auth.login.registerLink')}
</Link>
) : (
<span>{t('auth.login.registrationDisabled')}</span>
)}
</p>
<p className="mt-2 text-xs text-fg-muted">{t('auth.login.forgot')}</p>
</Card>
<footer className="flex items-center justify-between text-xs text-fg-muted">
<Link className="underline" to="/status">
{t('auth.login.instanceStatus')}
</Link>
<span>{instance.data?.version ?? ''}</span>
</footer>
</main>
);
}