From 538cfb9c5bcddbf3f12701f69a45aa1eab3432f7 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Wed, 10 Jun 2026 14:07:07 +0300 Subject: [PATCH] feat(auth): registration mode on ConnectPage (PUBLIC_ENABLE_REGISTRATION) Add a login/register toggle to ConnectPage backed by a new useRegisterMutation (register -> /auth/me, mirroring login). The toggle is shown only when REGISTRATION_ENABLED, resolved with the same precedence as the API base URL: runtime window.__APP_CONFIG__ > PUBLIC_ENABLE_REGISTRATION env > default true. The prod runtime-config script injects the runtime flag. The backend's ALLOW_REGISTRATION stays the real authority; this only gates the UI. EN/RU strings added. Co-Authored-By: Claude Opus 4.8 --- .env.example | 4 + dockerfiles/30-runtime-config.sh | 20 +++-- src/api/endpoints/auth.ts | 32 +++++++- src/api/types.ts | 5 ++ src/config/env.ts | 19 +++++ src/env.d.ts | 2 + src/features/connect/ConnectPage.tsx | 108 +++++++++++++++++++++++++-- src/i18n/locales/en.ts | 19 ++++- src/i18n/locales/ru.ts | 12 +++ 9 files changed, 206 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index fa70d74..642235b 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ # Default backend URL (overridable at runtime in the UI) PUBLIC_API_BASE_URL=http://localhost:8080/api/v1 + +# Show the public sign-up UI on the connect screen. Set to false to hide it. +# The backend's ALLOW_REGISTRATION is the real authority; this only gates the UI. +PUBLIC_ENABLE_REGISTRATION=true diff --git a/dockerfiles/30-runtime-config.sh b/dockerfiles/30-runtime-config.sh index d2985b4..68090d3 100755 --- a/dockerfiles/30-runtime-config.sh +++ b/dockerfiles/30-runtime-config.sh @@ -2,15 +2,25 @@ # Write the SPA's runtime operator config at container start. # # The nginx base image runs every /docker-entrypoint.d/*.sh before launching -# nginx, so this overwrites the build-time public/config.js stub with the value -# of $PUBLIC_API_BASE_URL. That lets one prebuilt image target any backend -# origin without rebuilding. Resolution + precedence live in src/config/env.ts. +# nginx, so this overwrites the build-time public/config.js stub with the +# operator's runtime config ($PUBLIC_API_BASE_URL, $PUBLIC_ENABLE_REGISTRATION). +# That lets one prebuilt image target any backend origin and toggle sign-up +# without rebuilding. Resolution + precedence live in src/config/env.ts. set -eu : "${PUBLIC_API_BASE_URL:=/api/v1}" +: "${PUBLIC_ENABLE_REGISTRATION:=true}" ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}" -printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s"};\n' "$PUBLIC_API_BASE_URL" \ +# Anything but "false"/"0" enables the sign-up UI (mirrors parseFlag in env.ts). +if [ "$PUBLIC_ENABLE_REGISTRATION" = "false" ] || [ "$PUBLIC_ENABLE_REGISTRATION" = "0" ]; then + ENABLE_REGISTRATION=false +else + ENABLE_REGISTRATION=true +fi + +printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s","enableRegistration":%s};\n' \ + "$PUBLIC_API_BASE_URL" "$ENABLE_REGISTRATION" \ >"$ROOT/config.js" -echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL to $ROOT/config.js" +echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL enableRegistration=$ENABLE_REGISTRATION to $ROOT/config.js" diff --git a/src/api/endpoints/auth.ts b/src/api/endpoints/auth.ts index 792ac90..abe5280 100644 --- a/src/api/endpoints/auth.ts +++ b/src/api/endpoints/auth.ts @@ -1,6 +1,12 @@ import { api } from '../index'; import { toUser, type RawUser } from '../mappers'; -import type { AuthTokens, LoginRequest, LoginResponse, User } from '../types'; +import type { + AuthTokens, + LoginRequest, + LoginResponse, + RegisterRequest, + User, +} from '../types'; /** * Auth seam over the backend's wire format: tokens-only login + a separate @@ -48,6 +54,29 @@ export const authApi = api.injectEndpoints({ return { data: { user, tokens } }; }, }), + // Sign-up mirrors login: POST /auth/register returns a token pair (the + // backend logs the new account straight in), then GET /auth/me resolves the + // user — so the UI gets the same unified { user, tokens } as login. + register: build.mutation({ + async queryFn(body, _api, _extra, baseQuery) { + const tokenRes = await baseQuery({ + url: '/auth/register', + method: 'POST', + body, + }); + if (tokenRes.error) return { error: tokenRes.error }; + const tokens = toTokens(tokenRes.data as RawTokenResponse); + + const meRes = await baseQuery({ + url: '/auth/me', + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }); + if (meRes.error) return { error: meRes.error }; + const user = toUser(meRes.data as RawUser); + + return { data: { user, tokens } }; + }, + }), logout: build.mutation({ query: ({ refreshToken }) => ({ url: '/auth/logout', @@ -74,6 +103,7 @@ export const authApi = api.injectEndpoints({ export const { useLoginMutation, + useRegisterMutation, useLogoutMutation, useRefreshTokenMutation, useMeQuery, diff --git a/src/api/types.ts b/src/api/types.ts index f5cdf31..d956090 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -113,6 +113,11 @@ export interface LoginResponse { tokens: AuthTokens; } +export interface RegisterRequest { + username: string; + password: string; +} + export interface PaginatedResponse { items: T[]; total: number; diff --git a/src/config/env.ts b/src/config/env.ts index 3e0393b..a692293 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -20,3 +20,22 @@ function runtimeApiBaseUrl(): string | undefined { export const DEFAULT_API_BASE_URL = runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1'; + +/** + * Whether the public sign-up UI is shown. Same precedence as the base URL: + * runtime operator config (injected into `window.__APP_CONFIG__` at container + * start) wins over the build-time `PUBLIC_ENABLE_REGISTRATION` env, which + * defaults to enabled. This only gates the *UI*; the backend independently + * enforces `ALLOW_REGISTRATION` and is the real authority. + */ +function parseFlag(value: string | undefined): boolean | undefined { + if (value == null || value === '') return undefined; + return value !== 'false' && value !== '0'; +} + +export const REGISTRATION_ENABLED: boolean = + (typeof window !== 'undefined' + ? window.__APP_CONFIG__?.enableRegistration + : undefined) ?? + parseFlag(import.meta.env.PUBLIC_ENABLE_REGISTRATION) ?? + true; diff --git a/src/env.d.ts b/src/env.d.ts index 86c7db9..74577bb 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,6 +1,7 @@ /// interface ImportMetaEnv { readonly PUBLIC_API_BASE_URL?: string; + readonly PUBLIC_ENABLE_REGISTRATION?: string; } interface ImportMeta { readonly env: ImportMetaEnv; @@ -11,5 +12,6 @@ interface ImportMeta { interface Window { __APP_CONFIG__?: { apiBaseUrl?: string; + enableRegistration?: boolean; }; } diff --git a/src/features/connect/ConnectPage.tsx b/src/features/connect/ConnectPage.tsx index 13d2ef2..fbc5791 100644 --- a/src/features/connect/ConnectPage.tsx +++ b/src/features/connect/ConnectPage.tsx @@ -7,7 +7,11 @@ import { Icon } from '../../components/common/Icon'; import { useAppDispatch } from '../../hooks/useAppDispatch'; import { setTokens, setUser } from '../../store/slices/auth'; import { setApiBaseUrl } from '../../config/runtime-config'; -import { useLoginMutation } from '../../api/endpoints/auth'; +import { + useLoginMutation, + useRegisterMutation, +} from '../../api/endpoints/auth'; +import { REGISTRATION_ENABLED } from '../../config/env'; import { listInstances, getActiveInstanceId, @@ -15,6 +19,8 @@ import { removeInstance, } from '../../config/instances'; +type Mode = 'login' | 'register'; + /** Map an RTKQ login failure to a user-facing i18n key. */ function resolveLoginError(err: unknown): string { const e = err as FetchBaseQueryError | undefined; @@ -25,6 +31,18 @@ function resolveLoginError(err: unknown): string { return 'connect.errors.generic'; } +/** Map an RTKQ register failure to a user-facing i18n key. */ +function resolveRegisterError(err: unknown): string { + const e = err as FetchBaseQueryError | undefined; + if (e && 'status' in e) { + if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable'; + if (e.status === 409) return 'connect.errors.usernameTaken'; + if (e.status === 422) return 'connect.errors.passwordTooShort'; + if (e.status === 403) return 'connect.errors.registrationDisabled'; + } + return 'connect.errors.registerFailed'; +} + export function ConnectPage() { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -34,12 +52,20 @@ export function ConnectPage() { const instances = listInstances(); const activeId = getActiveInstanceId(); + const [mode, setMode] = useState('login'); const [apiUrl, setApiUrl] = useState('https://'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); - const [login, { isLoading }] = useLoginMutation(); + const [login, { isLoading: isLoggingIn }] = useLoginMutation(); + const [register, { isLoading: isRegistering }] = useRegisterMutation(); + const isLoading = isLoggingIn || isRegistering; + + const switchMode = (next: Mode) => { + setMode(next); + setError(null); + }; const switchTo = (id: string) => { setActiveInstanceId(id); @@ -60,12 +86,20 @@ export function ConnectPage() { setApiBaseUrl(apiUrl); try { - const { user, tokens } = await login({ username, password }).unwrap(); + const action = + mode === 'register' + ? register({ username, password }) + : login({ username, password }); + const { user, tokens } = await action.unwrap(); dispatch(setTokens(tokens)); dispatch(setUser(user)); void navigate('/'); } catch (err) { - setError(resolveLoginError(err)); + setError( + mode === 'register' + ? resolveRegisterError(err) + : resolveLoginError(err), + ); } }; @@ -207,7 +241,11 @@ export function ConnectPage() { padding: '1.5rem', }} > - {t('connect.form.title')} + + {mode === 'register' + ? t('connect.form.registerTitle') + : t('connect.form.title')} +
setPassword(e.target.value)} placeholder="password" - autoComplete="current-password" + autoComplete={ + mode === 'register' ? 'new-password' : 'current-password' + } required /> + {mode === 'register' && ( + + {t('connect.form.passwordHint')} + + )}
{error && {t(error)}} + + {REGISTRATION_ENABLED && ( +
+ {mode === 'register' ? ( + <> + {t('connect.form.haveAccount')}{' '} + + + ) : ( + <> + {t('connect.form.noAccount')}{' '} + + + )} +
+ )} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 50729b1..68ee6a9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -31,16 +31,29 @@ const en = { forgetTitle: 'Forget this instance', form: { title: 'Connect to a backend', + registerTitle: 'Create an account', serverUrl: 'Server URL', username: 'Username', password: 'Password', + passwordHint: 'At least 8 characters.', submit: 'Connect', submitting: 'Connecting…', + registerSubmit: 'Create account', + registering: 'Creating account…', + noAccount: "Don't have an account?", + registerLink: 'Sign up', + haveAccount: 'Already have an account?', + signInLink: 'Sign in', }, errors: { - unreachable: "Can't reach this server. Check the URL and that it's online.", + unreachable: + "Can't reach this server. Check the URL and that it's online.", badCredentials: 'Incorrect username or password.', generic: 'Sign-in failed. Please try again.', + usernameTaken: 'That username is already taken.', + passwordTooShort: 'Password must be at least 8 characters.', + registrationDisabled: 'Registration is disabled on this server.', + registerFailed: 'Could not create the account. Please try again.', }, }, library: { @@ -211,6 +224,8 @@ const en = { export default en; type DeepString = { - [K in keyof T]: T[K] extends Record ? DeepString : string; + [K in keyof T]: T[K] extends Record + ? DeepString + : string; }; export type Translations = DeepString; diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 8eddb39..a71f821 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -33,17 +33,29 @@ const ru: Translations = { forgetTitle: 'Забыть этот сервер', form: { title: 'Подключиться к серверу', + registerTitle: 'Создать аккаунт', serverUrl: 'URL сервера', username: 'Имя пользователя', password: 'Пароль', + passwordHint: 'Не менее 8 символов.', submit: 'Подключиться', submitting: 'Подключение…', + registerSubmit: 'Создать аккаунт', + registering: 'Создание аккаунта…', + noAccount: 'Нет аккаунта?', + registerLink: 'Зарегистрироваться', + haveAccount: 'Уже есть аккаунт?', + signInLink: 'Войти', }, errors: { unreachable: 'Не удаётся подключиться к серверу. Проверьте URL и доступность.', badCredentials: 'Неверное имя пользователя или пароль.', generic: 'Не удалось войти. Попробуйте ещё раз.', + usernameTaken: 'Это имя пользователя уже занято.', + passwordTooShort: 'Пароль должен содержать не менее 8 символов.', + registrationDisabled: 'Регистрация на этом сервере отключена.', + registerFailed: 'Не удалось создать аккаунт. Попробуйте ещё раз.', }, }, library: {