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
+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>