From facc2154506035fda5fc71708b89d4e151bcc3e3 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Sat, 13 Jun 2026 12:35:20 +0300 Subject: [PATCH] chore: update/make more clear connect flow --- src/config/instances.ts | 5 + src/features/connect/ConnectPage.tsx | 504 ++++++++++++++++----------- src/hooks/useConnectionStatus.ts | 8 +- src/i18n/locales/en.ts | 37 +- src/i18n/locales/ru.ts | 35 +- 5 files changed, 364 insertions(+), 225 deletions(-) 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')} + ) : ( + + )} + + + + } + footer={ +
+ + + +
+ } + /> +
+ ); +} + 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')} - ) : ( - - )} - -
- ))} -
-
- )} - -
- - {mode === 'register' - ? t('connect.form.registerTitle') - : t('connect.form.title')} - -
- - setApiUrl(e.target.value)} - placeholder="https://your-server.example.com" - required - /> -
-
- - setUsername(e.target.value)} - placeholder="username" - autoComplete="username" - required - /> -
-
- - setPassword(e.target.value)} - placeholder="password" - autoComplete={ - mode === 'register' ? 'new-password' : 'current-password' - } - required - /> - {mode === 'register' && ( - - {t('connect.form.passwordHint')} + {instances.length > 0 && ( + <> + + {t('connect.domains.title')} - )} -
- {error && {t(error)}} - + setAddUrl(e.target.value)} + placeholder={t('connect.domains.addPlaceholder')} + style={{ flex: 1 }} + /> + + + +
- {REGISTRATION_ENABLED && ( -
- {mode === 'register' ? ( - <> - {t('connect.form.haveAccount')}{' '} - - - ) : ( - <> - {t('connect.form.noAccount')}{' '} - - + {selectedInstance && ( + +
+ + {mode === 'register' + ? t('connect.login.registerTitle', { + name: selectedInstance.name, + }) + : t('connect.login.title', { name: selectedInstance.name })} + +
+ + setUsername(e.target.value)} + placeholder="username" + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + placeholder="password" + autoComplete={ + mode === 'register' ? 'new-password' : 'current-password' + } + required + /> + {mode === 'register' && ( + + {t('connect.login.passwordHint')} + )}
- )} -
-
+ {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: 'Уже есть аккаунт?',