chore: update/make more clear connect flow

This commit is contained in:
Senko-san
2026-06-13 12:35:20 +03:00
parent 98e9344261
commit facc215450
5 changed files with 364 additions and 225 deletions
+5
View File
@@ -98,6 +98,11 @@ export function upsertInstance(url: string, name?: string): Instance {
return inst;
}
/** Clear a backend's stored session without forgetting the instance itself. */
export function clearInstanceAuth(id: string): void {
localStorage.removeItem(scopedKey('auth', id));
}
/** Remove a backend and wipe every scoped key it owns. */
export function removeInstance(id: string): void {
writeRegistry(readRegistry().filter((i) => i.id !== id));
+206 -96
View File
@@ -2,11 +2,11 @@ import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
import { Card, TextField, Button, Callout, Badge, Dialog } from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl } from '../../config/runtime-config';
import {
useLoginMutation,
useRegisterMutation,
@@ -17,10 +17,27 @@ import {
getActiveInstanceId,
setActiveInstanceId,
removeInstance,
clearInstanceAuth,
upsertInstance,
type Instance,
} from '../../config/instances';
type Mode = 'login' | 'register';
const HEALTH_VARIANTS = {
connected: 'lime',
connecting: 'neutral',
disconnected: 'ember',
error: 'ember',
} as const;
const HEALTH_KEY = {
connected: 'conn.connected',
connecting: 'conn.connecting',
disconnected: 'conn.disconnected',
error: 'conn.error',
} as const;
/** Map an RTKQ login failure to a user-facing i18n key. */
function resolveLoginError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined;
@@ -43,6 +60,121 @@ function resolveRegisterError(err: unknown): string {
return 'connect.errors.registerFailed';
}
function InstanceRow({
inst,
selected,
onSelect,
onLogout,
onRemove,
}: {
inst: Instance;
selected: boolean;
onSelect: () => void;
onLogout: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const status = useConnectionStatus(inst.baseUrl);
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
}}
>
<Badge variant={HEALTH_VARIANTS[status]} dot>
{t(HEALTH_KEY[status])}
</Badge>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{selected ? (
<Badge variant="outline">{t('connect.domains.selected')}</Badge>
) : (
<Button variant="ghost" size="sm" onClick={onSelect}>
{t('connect.domains.use')}
</Button>
)}
<Dialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={t('connect.removeDialog.title')}
description={t('connect.removeDialog.description', {
name: inst.name,
})}
trigger={
<button
type="button"
className="iconbtn sm"
title={t('connect.domains.forgetTitle')}
>
<Icon name="trash" />
</button>
}
footer={
<div
style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}
>
<Button
variant="ghost"
size="sm"
onClick={() => setDialogOpen(false)}
>
{t('connect.removeDialog.cancel')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDialogOpen(false);
onLogout();
}}
>
{t('connect.removeDialog.logout')}
</Button>
<Button
variant="ember"
size="sm"
onClick={() => {
setDialogOpen(false);
onRemove();
}}
>
{t('connect.removeDialog.removeAndLogout')}
</Button>
</div>
}
/>
</div>
);
}
export function ConnectPage() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
@@ -50,10 +182,15 @@ export function ConnectPage() {
const [rev, setRev] = useState(0);
const instances = listInstances();
const activeId = getActiveInstanceId();
const [selectedId, setSelectedId] = useState<string | null>(() =>
getActiveInstanceId(),
);
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
const [addUrl, setAddUrl] = useState('https://');
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);
@@ -67,23 +204,40 @@ export function ConnectPage() {
setError(null);
};
const switchTo = (id: string) => {
// Switching the active instance and reloading lets the app pick the saved
// session for that instance back up (if any); if it has none, ProtectedRoute
// bounces back here and `selectedId` defaults to it, surfacing the login card.
const selectInstance = (id: string) => {
setActiveInstanceId(id);
window.location.assign('/');
};
const forget = (id: string) => {
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const url = addUrl.trim();
if (!url || url === 'https://') return;
const inst = upsertInstance(url);
setActiveInstanceId(inst.id);
setAddUrl('https://');
setSelectedId(inst.id);
setRev((r) => r + 1);
};
const handleLogout = (id: string) => {
clearInstanceAuth(id);
setRev((r) => r + 1);
};
const handleRemove = (id: string) => {
removeInstance(id);
if (selectedId === id) setSelectedId(getActiveInstanceId());
setRev((r) => r + 1);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedInstance) return;
setError(null);
// Point the API layer at this backend *before* logging in — baseQuery reads
// the active instance's URL at request time. Auth tokens then persist under
// that instance's namespace, never bleeding across servers.
setApiBaseUrl(apiUrl);
try {
const action =
@@ -145,7 +299,6 @@ export function ConnectPage() {
<Icon name="vinyl-record" fill /> MCMA
</h1>
{instances.length > 0 && (
<Card>
<div
style={{
@@ -155,82 +308,45 @@ export function ConnectPage() {
gap: '0.5rem',
}}
>
{instances.length > 0 && (
<>
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
{t('connect.savedInstances')}
{t('connect.domains.title')}
</span>
{instances.map((inst) => (
<div
<InstanceRow
key={inst.id}
inst={inst}
selected={inst.id === selectedId}
onSelect={() => selectInstance(inst.id)}
onLogout={() => handleLogout(inst.id)}
onRemove={() => handleRemove(inst.id)}
/>
))}
</>
)}
<form
onSubmit={handleAdd}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
gap: '0.5rem',
marginTop: instances.length > 0 ? '0.5rem' : 0,
}}
>
<span
className={`led ${inst.id === activeId ? 'online' : 'offline'}`}
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
inst.id === activeId ? 'var(--lime)' : 'var(--fg-3)',
boxShadow:
inst.id === activeId ? '0 0 6px var(--lime)' : 'none',
flexShrink: 0,
}}
<TextField
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
placeholder={t('connect.domains.addPlaceholder')}
style={{ flex: 1 }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{inst.id === activeId ? (
<Badge variant="lime">{t('connect.active')}</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => switchTo(inst.id)}
>
{t('connect.use')}
<Button type="submit" variant="primary">
<Icon name="plus" /> {t('connect.domains.addButton')}
</Button>
)}
<button
type="button"
className="iconbtn sm"
onClick={() => forget(inst.id)}
title={t('connect.forgetTitle')}
>
<Icon name="trash" />
</button>
</div>
))}
</form>
</div>
</Card>
)}
{selectedInstance && (
<Card>
<form
onSubmit={handleSubmit}
@@ -243,20 +359,13 @@ export function ConnectPage() {
>
<span className="msk-label">
{mode === 'register'
? t('connect.form.registerTitle')
: t('connect.form.title')}
? t('connect.login.registerTitle', {
name: selectedInstance.name,
})
: t('connect.login.title', { name: selectedInstance.name })}
</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"
required
/>
</div>
<div>
<label style={labelStyle}>{t('connect.form.username')}</label>
<label style={labelStyle}>{t('connect.login.username')}</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -266,7 +375,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={labelStyle}>{t('connect.form.password')}</label>
<label style={labelStyle}>{t('connect.login.password')}</label>
<TextField
type="password"
value={password}
@@ -286,7 +395,7 @@ export function ConnectPage() {
marginTop: '0.375rem',
}}
>
{t('connect.form.passwordHint')}
{t('connect.login.passwordHint')}
</span>
)}
</div>
@@ -299,11 +408,11 @@ export function ConnectPage() {
>
{isLoading
? mode === 'register'
? t('connect.form.registering')
: t('connect.form.submitting')
? t('connect.login.registering')
: t('connect.login.submitting')
: mode === 'register'
? t('connect.form.registerSubmit')
: t('connect.form.submit')}
? t('connect.login.registerSubmit')
: t('connect.login.submit')}
</Button>
{REGISTRATION_ENABLED && (
@@ -316,26 +425,26 @@ export function ConnectPage() {
>
{mode === 'register' ? (
<>
{t('connect.form.haveAccount')}{' '}
{t('connect.login.haveAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('login')}
>
{t('connect.form.signInLink')}
{t('connect.login.signInLink')}
</Button>
</>
) : (
<>
{t('connect.form.noAccount')}{' '}
{t('connect.login.noAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('register')}
>
{t('connect.form.registerLink')}
{t('connect.login.registerLink')}
</Button>
</>
)}
@@ -343,6 +452,7 @@ export function ConnectPage() {
)}
</form>
</Card>
)}
</div>
</div>
);
+5 -3
View File
@@ -3,7 +3,9 @@ import { getApiBaseUrl } from '../config/runtime-config';
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
export function useConnectionStatus() {
/** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
export function useConnectionStatus(baseUrl?: string) {
const url = baseUrl ?? getApiBaseUrl();
const [status, setStatus] = useState<ConnectionStatus>('connecting');
useEffect(() => {
@@ -13,7 +15,7 @@ export function useConnectionStatus() {
if (cancelled) return;
setStatus('connecting');
try {
const res = await fetch(`${getApiBaseUrl()}/health`, {
const res = await fetch(`${url}/health`, {
signal: AbortSignal.timeout(5000),
});
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
@@ -30,7 +32,7 @@ export function useConnectionStatus() {
cancelled = true;
clearInterval(interval);
};
}, []);
}, [url]);
return status;
}
+23 -12
View File
@@ -25,25 +25,36 @@ const en = {
signOut: 'Sign out',
},
connect: {
savedInstances: 'Saved instances',
active: 'active',
domains: {
title: 'Saved instances',
addPlaceholder: 'https://your-server.example.com',
addButton: 'Add instance',
selected: 'Selected',
use: 'Use',
forgetTitle: 'Forget this instance',
form: {
title: 'Connect to a backend',
registerTitle: 'Create an account',
serverUrl: 'Server URL',
forgetTitle: 'Remove this instance',
},
removeDialog: {
title: 'Remove cached data?',
description:
'This removes "{{name}}" from your saved instances and clears its cached data on this device.',
cancel: 'Cancel',
logout: 'Just log out',
removeAndLogout: 'Remove data & log out',
},
login: {
title: 'Log in to {{name}}',
registerTitle: 'Sign up for {{name}}',
username: 'Username',
password: 'Password',
passwordHint: 'At least 8 characters.',
submit: 'Connect',
submitting: 'Connecting…',
registerSubmit: 'Create account',
registering: 'Creating account…',
submit: 'Log in',
submitting: 'Logging in…',
registerSubmit: 'Sign up',
registering: 'Signing up…',
noAccount: "Don't have an account?",
registerLink: 'Sign up',
haveAccount: 'Already have an account?',
signInLink: 'Sign in',
signInLink: 'Log in',
},
errors: {
unreachable:
+22 -11
View File
@@ -27,21 +27,32 @@ const ru: Translations = {
signOut: 'Выйти',
},
connect: {
savedInstances: 'Сохранённые серверы',
active: 'активный',
domains: {
title: 'Сохранённые серверы',
addPlaceholder: 'https://your-server.example.com',
addButton: 'Добавить сервер',
selected: 'Выбран',
use: 'Выбрать',
forgetTitle: 'Забыть этот сервер',
form: {
title: 'Подключиться к серверу',
registerTitle: 'Создать аккаунт',
serverUrl: 'URL сервера',
forgetTitle: 'Удалить этот сервер',
},
removeDialog: {
title: 'Удалить локальные данные?',
description:
'Сервер «{{name}}» будет удалён из сохранённых, а его данные на этом устройстве — очищены.',
cancel: 'Отмена',
logout: 'Просто выйти',
removeAndLogout: 'Удалить данные и выйти',
},
login: {
title: 'Вход в {{name}}',
registerTitle: 'Регистрация на {{name}}',
username: 'Имя пользователя',
password: 'Пароль',
passwordHint: 'Не менее 8 символов.',
submit: 'Подключиться',
submitting: 'Подключение…',
registerSubmit: 'Создать аккаунт',
registering: 'Создание аккаунта…',
submit: 'Войти',
submitting: 'Вход…',
registerSubmit: 'Зарегистрироваться',
registering: 'Регистрация…',
noAccount: 'Нет аккаунта?',
registerLink: 'Зарегистрироваться',
haveAccount: 'Уже есть аккаунт?',