diff --git a/web/package.json b/web/package.json index e6f4175..dfb4f18 100644 --- a/web/package.json +++ b/web/package.json @@ -30,7 +30,7 @@ "daisyui": "^4.12.10", "tailwindcss": "^3.4.13", "typescript": "^5.7.3", - "vitest": "^3.2.7" + "vitest": "3.1.4" }, "optionalDependencies": { "@biomejs/cli-linux-x64-musl": "^1.9.4", diff --git a/web/src/auth/DevLogin.tsx b/web/src/auth/DevLogin.tsx index ef5a76d..b6b6724 100644 --- a/web/src/auth/DevLogin.tsx +++ b/web/src/auth/DevLogin.tsx @@ -2,12 +2,14 @@ import { Alert, Button, Form, Input } from 'antd'; import { useState } from 'react'; import { api } from '../api/client'; import type { DevTokenResponse } from '../api/types'; +import { isDevIssuerDisabled } from './devIssuer'; import { setToken } from './token'; type Props = { onLogin: () => void; error?: string | null }; -// Вход по email для разработки: бэкенд выпускает токен только при AUTH_DEV_ISSUER=true и DEBUG=true. -function DevLoginForm({ onLogin, error }: Props) { +// Вход по email для разработки и контура без SSO: бэкенд выпускает токен только при AUTH_DEV_ISSUER=true и DEBUG=true. +// `onDisabled` — бэкенд ответил, что маршрута /dev/token нет (dev-эмитент выключен): вход возможен только через SSO. +function DevLoginForm({ onLogin, error, onDisabled }: Props & { onDisabled: () => void }) { const [submitting, setSubmitting] = useState(false); const [failure, setFailure] = useState(null); @@ -19,6 +21,10 @@ function DevLoginForm({ onLogin, error }: Props) { setToken(access_token); onLogin(); } catch (e: unknown) { + if (isDevIssuerDisabled(e)) { + onDisabled(); + return; + } setFailure(e instanceof Error ? e.message : 'Не удалось выполнить вход'); } finally { setSubmitting(false); @@ -41,8 +47,8 @@ function DevLoginForm({ onLogin, error }: Props) { ); } -// Production: вход выполняет внешний SSO; если загрузка профиля не удалась, показываем причину и даём повторить запрос -function ProdNotice({ onLogin, error }: Props) { +// Dev-эмитент выключен: вход выполняет внешний SSO; если загрузка профиля не удалась, показываем причину и даём повторить запрос +function SsoNotice({ onLogin, error }: Props) { return (

Вход выполняется через РАИСА

@@ -54,15 +60,22 @@ function ProdNotice({ onLogin, error }: Props) { ); } +/** + * Какую форму показать, решает не режим сборки, а бэкенд во время выполнения: платформа всегда собирает фронт + * в production, при этом контур может работать на dev-заглушке аутентификации (переменные сборки в пайплайне + * задать нельзя). Поэтому форма входа по email показывается всегда, а заглушка про SSO — только после того, + * как бэкенд ответил, что маршрута /dev/token нет. + */ export function DevLogin(props: Props) { + const [ssoOnly, setSsoOnly] = useState(false); + return (
- {/* Литерал process.env.NODE_ENV rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */} - {process.env.NODE_ENV !== 'production' ? ( - + {ssoOnly ? ( + // TODO(OQ-54): параметры JWT РАИСА (издатель, claim с email) и сам сценарий входа через SSO + ) : ( - // TODO(OQ): параметры JWT РАИСА (издатель, claim с email) - + setSsoOnly(true)} /> )}
); diff --git a/web/src/auth/devIssuer.test.ts b/web/src/auth/devIssuer.test.ts new file mode 100644 index 0000000..f666e3c --- /dev/null +++ b/web/src/auth/devIssuer.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError } from '../api/client'; +import { isDevIssuerDisabled } from './devIssuer'; + +describe('isDevIssuerDisabled', () => { + it('404 «Not Found» без кода — маршрута /dev/token нет, dev-эмитент выключен', () => { + expect(isDevIssuerDisabled(new ApiError(404, undefined, 'Not Found', { detail: 'Not Found' }))).toBe(true); + }); + + it('404 «пользователь не найден или отключён» — маршрут есть, это ошибка входа', () => { + expect(isDevIssuerDisabled(new ApiError(404, undefined, 'пользователь не найден или отключён'))).toBe(false); + }); + + it('другие статусы и не-ApiError не считаются выключенным эмитентом', () => { + expect(isDevIssuerDisabled(new ApiError(409, undefined, 'неоднозначная учётная запись'))).toBe(false); + expect(isDevIssuerDisabled(new ApiError(0, 'NETWORK', 'Нет связи с сервером'))).toBe(false); + expect(isDevIssuerDisabled(new Error('Not Found'))).toBe(false); + expect(isDevIssuerDisabled(undefined)).toBe(false); + }); +}); diff --git a/web/src/auth/devIssuer.ts b/web/src/auth/devIssuer.ts new file mode 100644 index 0000000..fe0b126 --- /dev/null +++ b/web/src/auth/devIssuer.ts @@ -0,0 +1,10 @@ +import { ApiError } from '../api/client'; + +/** + * Маршрут `POST /dev/token` существует только при `DEBUG=true` и `AUTH_DEV_ISSUER=true` на бэкенде; иначе приложение + * отвечает 404 со строкой «Not Found» (а не доменной ошибкой с кодом). Это и есть признак «dev-эмитент выключен, + * вход только через SSO». 404 «пользователь не найден или отключён» приходит с того же статуса, но от самого маршрута. + */ +export function isDevIssuerDisabled(error: unknown): boolean { + return error instanceof ApiError && error.status === 404 && error.code === undefined && error.message === 'Not Found'; +}