feat(auth): registration mode on ConnectPage (PUBLIC_ENABLE_REGISTRATION)
Docker Build & Publish / build (push) Failing after 1m42s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-10 14:07:07 +03:00
parent 2ad3b128d6
commit 538cfb9c5b
9 changed files with 206 additions and 15 deletions
+4
View File
@@ -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
+15 -5
View File
@@ -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"
+31 -1
View File
@@ -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<LoginResponse, RegisterRequest>({
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<void, { refreshToken: string }>({
query: ({ refreshToken }) => ({
url: '/auth/logout',
@@ -74,6 +103,7 @@ export const authApi = api.injectEndpoints({
export const {
useLoginMutation,
useRegisterMutation,
useLogoutMutation,
useRefreshTokenMutation,
useMeQuery,
+5
View File
@@ -113,6 +113,11 @@ export interface LoginResponse {
tokens: AuthTokens;
}
export interface RegisterRequest {
username: string;
password: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
+19
View File
@@ -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;
+2
View File
@@ -1,6 +1,7 @@
/// <reference types="@rsbuild/core/types" />
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;
};
}
+101 -7
View File
@@ -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<Mode>('login');
const [apiUrl, setApiUrl] = useState('https://');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(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',
}}
>
<span className="msk-label">{t('connect.form.title')}</span>
<span className="msk-label">
{mode === 'register'
? t('connect.form.registerTitle')
: t('connect.form.title')}
</span>
<div>
<label style={labelStyle}>{t('connect.form.serverUrl')}</label>
<TextField
@@ -234,9 +272,23 @@ export function ConnectPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
autoComplete="current-password"
autoComplete={
mode === 'register' ? 'new-password' : 'current-password'
}
required
/>
{mode === 'register' && (
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
marginTop: '0.375rem',
}}
>
{t('connect.form.passwordHint')}
</span>
)}
</div>
{error && <Callout variant="danger">{t(error)}</Callout>}
<Button
@@ -245,8 +297,50 @@ export function ConnectPage() {
disabled={isLoading}
style={{ marginTop: '0.5rem' }}
>
{isLoading ? t('connect.form.submitting') : t('connect.form.submit')}
{isLoading
? mode === 'register'
? t('connect.form.registering')
: t('connect.form.submitting')
: mode === 'register'
? t('connect.form.registerSubmit')
: t('connect.form.submit')}
</Button>
{REGISTRATION_ENABLED && (
<div
style={{
textAlign: 'center',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{mode === 'register' ? (
<>
{t('connect.form.haveAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('login')}
>
{t('connect.form.signInLink')}
</Button>
</>
) : (
<>
{t('connect.form.noAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('register')}
>
{t('connect.form.registerLink')}
</Button>
</>
)}
</div>
)}
</form>
</Card>
</div>
+17 -2
View File
@@ -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<T> = {
[K in keyof T]: T[K] extends Record<string, unknown> ? DeepString<T[K]> : string;
[K in keyof T]: T[K] extends Record<string, unknown>
? DeepString<T[K]>
: string;
};
export type Translations = DeepString<typeof en>;
+12
View File
@@ -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: {