diff --git a/src/config/instances.ts b/src/config/instances.ts
index 2bfd59e..2ca0d9c 100644
--- a/src/config/instances.ts
+++ b/src/config/instances.ts
@@ -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));
diff --git a/src/features/connect/ConnectPage.tsx b/src/features/connect/ConnectPage.tsx
index fbc5791..21e7cd3 100644
--- a/src/features/connect/ConnectPage.tsx
+++ b/src/features/connect/ConnectPage.tsx
@@ -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 (
+
+
+ {t(HEALTH_KEY[status])}
+
+
+
+ {inst.name}
+
+
+ {inst.baseUrl}
+
+
+ {selected ? (
+
{t('connect.domains.selected')}
+ ) : (
+
+ )}
+
+ );
+}
+
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(() =>
+ getActiveInstanceId(),
+ );
+ const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
+
+ const [addUrl, setAddUrl] = useState('https://');
const [mode, setMode] = useState('login');
- const [apiUrl, setApiUrl] = useState('https://');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(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,204 +299,160 @@ export function ConnectPage() {
MCMA
- {instances.length > 0 && (
-
-
-
- {t('connect.savedInstances')}
-
- {instances.map((inst) => (
-
-
-
-
- {inst.name}
-
-
- {inst.baseUrl}
-
-
- {inst.id === activeId ? (
-
{t('connect.active')}
- ) : (
-
- )}
-
-
- ))}
-
-
- )}
-
-
+
+
- {REGISTRATION_ENABLED && (
-
- {mode === 'register' ? (
- <>
- {t('connect.form.haveAccount')}{' '}
-
- >
- ) : (
- <>
- {t('connect.form.noAccount')}{' '}
-
- >
+ {selectedInstance && (
+
+
-
+ {error &&
{t(error)}}
+
+
+ {REGISTRATION_ENABLED && (
+
+ {mode === 'register' ? (
+ <>
+ {t('connect.login.haveAccount')}{' '}
+
+ >
+ ) : (
+ <>
+ {t('connect.login.noAccount')}{' '}
+
+ >
+ )}
+
+ )}
+
+
+ )}
);
diff --git a/src/hooks/useConnectionStatus.ts b/src/hooks/useConnectionStatus.ts
index 83011ae..2871e26 100644
--- a/src/hooks/useConnectionStatus.ts
+++ b/src/hooks/useConnectionStatus.ts
@@ -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('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;
}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 68ee6a9..ee85b84 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -25,25 +25,36 @@ const en = {
signOut: 'Sign out',
},
connect: {
- savedInstances: 'Saved instances',
- active: 'active',
- use: 'Use',
- forgetTitle: 'Forget this instance',
- form: {
- title: 'Connect to a backend',
- registerTitle: 'Create an account',
- serverUrl: 'Server URL',
+ domains: {
+ title: 'Saved instances',
+ addPlaceholder: 'https://your-server.example.com',
+ addButton: 'Add instance',
+ selected: 'Selected',
+ use: 'Use',
+ 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:
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index a71f821..fa8eb61 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -27,21 +27,32 @@ const ru: Translations = {
signOut: 'Выйти',
},
connect: {
- savedInstances: 'Сохранённые серверы',
- active: 'активный',
- use: 'Выбрать',
- forgetTitle: 'Забыть этот сервер',
- form: {
- title: 'Подключиться к серверу',
- registerTitle: 'Создать аккаунт',
- serverUrl: 'URL сервера',
+ domains: {
+ title: 'Сохранённые серверы',
+ addPlaceholder: 'https://your-server.example.com',
+ addButton: 'Добавить сервер',
+ selected: 'Выбран',
+ use: 'Выбрать',
+ 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: 'Уже есть аккаунт?',