chore: update/make more clear connect flow
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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: 'Уже есть аккаунт?',
|
||||
|
||||
Reference in New Issue
Block a user