Compare commits
2 Commits
55aa8933af
...
538cfb9c5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 538cfb9c5b | |||
| 2ad3b128d6 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -113,6 +113,11 @@ export interface LoginResponse {
|
||||
tokens: AuthTokens;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,8 +29,13 @@ const ACTIVE_KEY = 'mcma:activeInstance';
|
||||
const LEGACY_URL_KEY = 'mcma_api_base_url';
|
||||
const LEGACY_AUTH_KEY = 'mcma_auth';
|
||||
|
||||
// The UI always talks to the `/api/v1` contract, so users only enter the
|
||||
// origin (and optional reverse-proxy prefix). We append the contract path
|
||||
// here, the single choke point for both the base URL and the instance id, so
|
||||
// `domain.com`, `domain.com/`, and `domain.com/api/v1` all converge.
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.trim().replace(/\/+$/, '');
|
||||
const trimmed = url.trim().replace(/\/+$/, '');
|
||||
return /\/api\/v1$/.test(trimmed) ? trimmed : `${trimmed}/api/v1`;
|
||||
}
|
||||
|
||||
/** Stable, readable id from a base URL — also serves as the storage namespace. */
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,13 +241,17 @@ 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
|
||||
value={apiUrl}
|
||||
onChange={(e) => setApiUrl(e.target.value)}
|
||||
placeholder="https://your-server.example.com/api/v1"
|
||||
placeholder="https://your-server.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user