refactor login flow and hooks, address autologin issues

This commit is contained in:
seavor 2026-04-18 10:14:31 -05:00
parent dcd6dc00f4
commit bd2382c94e
43 changed files with 2179 additions and 484 deletions

View file

@ -19,15 +19,15 @@ const types = (...types) => types.map((type) => ({ to: { type } }));
const rules = [
{ from: { type: 'generated' }, allow: [] },
{ from: { type: 'websocket' }, allow: types('generated') },
{ from: { type: 'types' }, allow: types('generated', 'websocket') },
{ from: { type: 'websocket' }, allow: types('generated') },
{ from: { type: 'store' }, allow: types('types') },
{ from: { type: 'api' }, allow: types('store', 'types', 'websocket') },
{ from: { type: 'hooks' }, allow: types('api', 'services', 'types', 'websocket') },
{ from: { type: 'images' }, allow: types('types') },
{ from: { type: 'services' }, allow: types('api', 'store', 'types') },
{ from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket') },
{
from: { type: 'components' },

View file

@ -0,0 +1,33 @@
// Shared render helper for the app integration suite.
//
// Two non-obvious choices:
//
// 1. WebClientContext is provided directly (not via production
// <WebClientProvider>) because the shared integration setup.ts already
// instantiates the WebClient singleton in beforeEach. The production
// provider would `new WebClient(...)` a second time and throw.
//
// 2. We pass the REAL Redux store from @app/store — not renderWithProviders'
// default test-local store. The real WebClient dispatches against the
// real store (that's what createWebClientResponse wires to). Asserting
// against a different in-memory store would silently miss every
// dispatch. setup.ts's resetAll + afterEach clears the real store
// between tests, so each test still starts from a clean slate.
import { ReactElement } from 'react';
import { renderWithProviders } from '../../../src/__test-utils__';
import { store } from '@app/store';
import { WebClientContext } from '@app/hooks';
import { WebClient } from '@app/websocket';
export function renderAppScreen(ui: ReactElement) {
return renderWithProviders(
<WebClientContext.Provider value={WebClient.instance}>
{ui}
</WebClientContext.Provider>,
{ store }
);
}
export { store };

View file

@ -0,0 +1,192 @@
// Full-stack autoconnect integration. Only outbound surfaces are mocked
// (WebSocket via the shared setup, IndexedDB via fake-indexeddb in setup).
// Everything in between — Dexie, DTOs, useSettings/useKnownHosts, useAutoLogin,
// the Login container, WebClient, Redux — runs as shipped code.
//
// We assert auto-login via `connectionAttemptMade` on the real server slice,
// not via the WebSocket mock's call count: KnownHosts fires a testConnection
// on mount for the UX indicator, which also constructs sockets, so raw
// socket counts are noisy. Only the login path dispatches CONNECTION_ATTEMPTED.
import { act, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Store loads notify React subscribers synchronously when the Dexie
// promise resolves. Awaiting whenReady() directly would let those
// notifications fire outside an act() scope, which trips React's
// "update was not wrapped in act" warning. Wrapping here captures
// both the store resolution and any resulting component re-renders.
const flushStoresAndEffects = async (): Promise<void> => {
await act(async () => {
await settingsStore.whenReady();
await knownHostsStore.whenReady();
// Let dependent effects (host-sync, settings-sync) commit.
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
import { autoLoginSession } from '../../../src/hooks/useAutoLogin';
import { settingsStore } from '../../../src/hooks/useSettings';
import { knownHostsStore } from '../../../src/hooks/useKnownHosts';
import Login from '../../../src/containers/Login/Login';
import { HostDTO, SettingDTO } from '@app/services';
import { App } from '@app/types';
import { ServerSelectors, ServerDispatch } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { resetDexie } from '../services/dexie/resetDexie';
import { renderAppScreen, store } from './helpers';
// Mimics the production "user logged out / connection dropped" transition:
// dispatching updateStatus(DISCONNECTED) is what the real reducer uses to
// clear connectionAttemptMade (clearStore intentionally preserves status).
const simulateLogout = () => {
ServerDispatch.updateStatus(StatusEnum.DISCONNECTED, null);
};
const seedAutoConnect = async () => {
const setting = new SettingDTO(App.APP_USER);
setting.autoConnect = true;
await setting.save();
const id = (await HostDTO.add({
name: 'Test Server',
host: 'server.example',
port: '4748',
editable: false,
})) as number;
const host = (await HostDTO.get(id))!;
host.remember = true;
host.userName = 'alice';
host.hashedPassword = 'stored-hash';
host.lastSelected = true;
await host.save();
};
const attempted = (): boolean =>
ServerSelectors.getConnectionAttemptMade(store.getState());
afterEach(async () => {
// Absorb any state updates that lingered past the test body (stores
// resolving after unmount, trailing effect commits) so they're wrapped
// in act and don't trip React's warning during teardown.
await flushStoresAndEffects();
});
beforeEach(async () => {
// setup.ts's beforeEach installs fake timers and re-creates the WebClient
// singleton. Dexie + React async need real timers; module caches persist
// across tests and need explicit reset.
vi.useRealTimers();
await resetDexie();
// Reset the module-level caches that load from Dexie. Without this, a
// test would read the PREVIOUS test's snapshot (the Dexie clear only
// truncates storage, not the useSettings / useKnownHosts subscribers'
// cached values).
settingsStore.reset();
knownHostsStore.reset();
autoLoginSession.startupCheckRan = false;
});
describe('autoconnect — cold start', () => {
it('auto-logs in when Dexie has autoConnect=true + host with stored credentials', async () => {
await seedAutoConnect();
renderAppScreen(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
});
it('does NOT attempt login when Dexie has no settings row', async () => {
renderAppScreen(<Login />);
await flushStoresAndEffects();
expect(attempted()).toBe(false);
});
it('does NOT attempt login when autoConnect=true but lastSelected host lacks credentials', async () => {
const setting = new SettingDTO(App.APP_USER);
setting.autoConnect = true;
await setting.save();
await HostDTO.add({
name: 'Unremembered',
host: 'server.example',
port: '4748',
editable: false,
lastSelected: true,
});
renderAppScreen(<Login />);
await flushStoresAndEffects();
expect(attempted()).toBe(false);
});
});
describe('autoconnect — logout cycle (same session)', () => {
it('does not auto-reconnect after first auto-login + logout within the same JS session', async () => {
await seedAutoConnect();
const first = renderAppScreen(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
// Simulate "logged out and returned to /login": unmount, clear the
// store's connectionAttemptMade flag (the app-level equivalent of
// DISCONNECTED → status.connectionAttemptMade reset), remount.
first.unmount();
simulateLogout();
renderAppScreen(<Login />);
await flushStoresAndEffects();
// The session gate must have kept useAutoLogin silent; the flag stays
// false.
expect(attempted()).toBe(false);
});
it('does not auto-connect when the user enabled autoConnect mid-session and then logged out', async () => {
// First mount with autoConnect=false — gate latches without firing.
const first = renderAppScreen(<Login />);
await flushStoresAndEffects();
expect(attempted()).toBe(false);
first.unmount();
// Mid-session: user ticked the checkbox → Dexie flipped to autoConnect=true.
await seedAutoConnect();
// Remount (post-logout). The gate MUST keep useAutoLogin silent.
renderAppScreen(<Login />);
await flushStoresAndEffects();
expect(attempted()).toBe(false);
});
});
describe('autoconnect — refresh', () => {
it('auto-connects again after resetting the session gate (page-refresh equivalent)', async () => {
await seedAutoConnect();
const first = renderAppScreen(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
first.unmount();
// Simulate a browser refresh: the session gate naturally resets on a
// fresh JS context, and the real connection flag resets too.
simulateLogout();
autoLoginSession.startupCheckRan = false;
renderAppScreen(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
});
});

View file

@ -9,6 +9,10 @@
import '@testing-library/jest-dom/vitest';
import '../../../src/polyfills';
// fake-indexeddb polyfills globalThis.indexedDB. MUST be imported before any
// module that opens a Dexie database (Dexie opens on first table access).
// Harmless for the websocket suite, which doesn't touch Dexie.
import 'fake-indexeddb/auto';
import { create } from '@bufbuild/protobuf';
import { afterEach, beforeEach, vi } from 'vitest';

View file

@ -0,0 +1,91 @@
// Real round-trip tests for HostDTO through Dexie into fake-indexeddb.
// Exercises the full static method surface (add, get, getAll, bulkAdd,
// delete) plus instance save().
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HostDTO } from '@app/services';
import type { App } from '@app/types';
import { resetDexie } from './resetDexie';
const makeRow = (overrides: Partial<App.Host> = {}): App.Host => ({
name: 'Test',
host: 'host.example',
port: '4747',
editable: false,
...overrides,
});
beforeEach(async () => {
// Shared setup.ts installs fake timers for the websocket suite's
// KeepAliveService; Dexie / fake-indexeddb need real timers.
vi.useRealTimers();
await resetDexie();
});
describe('HostDTO (real Dexie)', () => {
it('getAll returns empty on a fresh store', async () => {
const all = await HostDTO.getAll();
expect(all).toEqual([]);
});
it('add returns an auto-incremented id and makes the row retrievable by get(id)', async () => {
const id = (await HostDTO.add(makeRow({ name: 'A' }))) as number;
expect(typeof id).toBe('number');
const loaded = await HostDTO.get(id);
expect(loaded).toBeDefined();
expect(loaded!.name).toBe('A');
expect(loaded!.id).toBe(id);
expect(loaded).toBeInstanceOf(HostDTO);
});
it('bulkAdd seeds multiple rows and they are all retrievable via getAll', async () => {
await HostDTO.bulkAdd([
makeRow({ name: 'A' }),
makeRow({ name: 'B' }),
makeRow({ name: 'C' }),
]);
const all = await HostDTO.getAll();
expect(all.map((h) => h.name).sort()).toEqual(['A', 'B', 'C']);
});
it('save() on a loaded instance upserts the same row (does not duplicate)', async () => {
const id = (await HostDTO.add(makeRow({ name: 'A', remember: false }))) as number;
const loaded = await HostDTO.get(id);
loaded!.remember = true;
loaded!.userName = 'alice';
loaded!.hashedPassword = 'stored';
await loaded!.save();
const all = await HostDTO.getAll();
expect(all).toHaveLength(1);
expect(all[0].remember).toBe(true);
expect(all[0].userName).toBe('alice');
expect(all[0].hashedPassword).toBe('stored');
});
it('delete removes the row by id', async () => {
const idA = (await HostDTO.add(makeRow({ name: 'A' }))) as number;
await HostDTO.add(makeRow({ name: 'B' }));
await HostDTO.delete(idA as unknown as string);
const all = await HostDTO.getAll();
expect(all.map((h) => h.name)).toEqual(['B']);
});
it('lastSelected round-trips as a boolean column', async () => {
const idA = (await HostDTO.add(makeRow({ name: 'A', lastSelected: true }))) as number;
await HostDTO.add(makeRow({ name: 'B', lastSelected: false }));
const all = await HostDTO.getAll();
const selected = all.find((h) => h.id === idA)!;
expect(selected.lastSelected).toBe(true);
const other = all.find((h) => h.name === 'B')!;
expect(other.lastSelected).toBe(false);
});
});

View file

@ -0,0 +1,12 @@
// Clears every table the services suite touches so each test starts from
// empty storage. Dexie is a real singleton, the database a real (fake-
// indexeddb) instance, so state leaks between tests otherwise.
import { dexieService } from '@app/services';
export async function resetDexie(): Promise<void> {
await Promise.all([
dexieService.settings.clear(),
dexieService.hosts.clear(),
]);
}

View file

@ -0,0 +1,69 @@
// Real round-trip tests for SettingDTO through Dexie into fake-indexeddb.
// Nothing is mocked past the IndexedDB boundary — the DTO class, the Dexie
// schema, and the table's put/where/first pipeline all run as shipped code.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingDTO } from '@app/services';
import { App } from '@app/types';
import { resetDexie } from './resetDexie';
beforeEach(async () => {
// Shared setup.ts installs vi.useFakeTimers() for the websocket suite's
// KeepAliveService needs. Dexie + fake-indexeddb rely on real microtasks
// and will hang under fake timers, so flip back here.
vi.useRealTimers();
await resetDexie();
});
describe('SettingDTO (real Dexie)', () => {
it('returns undefined for a user with no row yet', async () => {
const loaded = await SettingDTO.get(App.APP_USER);
expect(loaded).toBeUndefined();
});
it('round-trips a fresh setting via save()', async () => {
const dto = new SettingDTO(App.APP_USER);
dto.autoConnect = true;
await dto.save();
const loaded = await SettingDTO.get(App.APP_USER);
expect(loaded).toBeDefined();
expect(loaded!.user).toBe(App.APP_USER);
expect(loaded!.autoConnect).toBe(true);
});
it('upserts on repeated save for the same user key', async () => {
const first = new SettingDTO(App.APP_USER);
first.autoConnect = false;
await first.save();
const loaded = await SettingDTO.get(App.APP_USER);
loaded!.autoConnect = true;
await loaded!.save();
const reloaded = await SettingDTO.get(App.APP_USER);
expect(reloaded!.autoConnect).toBe(true);
});
it('matches user lookups case-insensitively (equalsIgnoreCase in DTO.get)', async () => {
const dto = new SettingDTO(App.APP_USER);
await dto.save();
const loaded = await SettingDTO.get(App.APP_USER.toUpperCase());
expect(loaded).toBeDefined();
expect(loaded!.user).toBe(App.APP_USER);
});
it('preserves the SettingDTO class on load (mapToClass binding)', async () => {
const dto = new SettingDTO(App.APP_USER);
await dto.save();
const loaded = await SettingDTO.get(App.APP_USER);
expect(loaded).toBeInstanceOf(SettingDTO);
// The save() instance method must be present on the retrieved row so
// call sites (useSettings.update) can round-trip without reinstantiation.
expect(typeof loaded!.save).toBe('function');
});
});

View file

@ -8,14 +8,14 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { AdminCommands } from '@app/websocket';
import { connectAndLogin } from './helpers/setup';
import { connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastAdminCommand } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastAdminCommand } from '../helpers/command-capture';
describe('admin commands', () => {
it('adjustMod modifies the user level bitflags on success', () => {

View file

@ -8,13 +8,13 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum, WebSocketConnectReason } from '@app/websocket';
import { connectAndHandshake, connectAndHandshakeWithSalt } from './helpers/setup';
import { connectAndHandshake, connectAndHandshakeWithSalt } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
function makeUser(name: string): Data.ServerInfo_User {
return create(Data.ServerInfo_UserSchema, {

View file

@ -9,7 +9,7 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { PROTOCOL_VERSION } from '../../src/websocket/config';
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
import {
getMockWebSocket,
@ -17,14 +17,14 @@ import {
openMockWebSocket,
setPendingOptions,
connectAndHandshake,
} from './helpers/setup';
} from '../helpers/setup';
import type { WebSocketConnectOptions } from '@app/websocket';
import { WebSocketConnectReason } from '@app/websocket';
import {
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebSocketConnectOptions {
return {

View file

@ -8,13 +8,13 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { SessionCommands } from '@app/websocket';
import { connectAndLogin } from './helpers/setup';
import { connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
describe('deck operations', () => {
it('populates backendDecks from deckList response', () => {

View file

@ -8,7 +8,7 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { GameCommands, RoomCommands } from '@app/websocket';
import { connectAndHandshake, connectAndLogin } from './helpers/setup';
import { connectAndHandshake, connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
@ -16,8 +16,8 @@ import {
buildRoomEventMessage,
buildGameEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from '../helpers/command-capture';
function joinGame(gameId: number): void {
deliverMessage(buildSessionEventMessage(

View file

@ -6,13 +6,13 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { connectRaw, getMockWebSocket } from './helpers/setup';
import { connectRaw, getMockWebSocket } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
describe('keep-alive', () => {
it('sends a Command_Ping on every keepalive interval tick', () => {

View file

@ -9,13 +9,13 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { ModeratorCommands } from '@app/websocket';
import { connectAndLogin } from './helpers/setup';
import { connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastModeratorCommand } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastModeratorCommand } from '../helpers/command-capture';
describe('moderator commands', () => {
it('getBanHistory populates server.banHistory on success', () => {

View file

@ -8,13 +8,13 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum, WebSocketConnectReason } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup';
import { connectAndHandshake } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
describe('password reset', () => {
it('forgotPasswordRequest sends command and disconnects on success', () => {

View file

@ -8,15 +8,15 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { RoomCommands } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup';
import { connectAndHandshake } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildRoomEventMessage,
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from '../helpers/command-capture';
import { fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
function makeRoom(overrides: Partial<{

View file

@ -8,11 +8,11 @@ import { Data } from '@app/types';
import { store } from '@app/store';
import { StatusEnum } from '@app/websocket';
import { connectAndHandshake } from './helpers/setup';
import { connectAndHandshake } from '../helpers/setup';
import {
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
} from '../helpers/protobuf-builders';
describe('server events', () => {
it('writes the server banner into server.info.message on Event_ServerMessage', () => {

View file

@ -6,14 +6,14 @@ import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { connectAndLogin } from './helpers/setup';
import { connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
deliverMessage,
} from './helpers/protobuf-builders';
import { findLastSessionCommand } from './helpers/command-capture';
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
function makeUser(name: string): Data.ServerInfo_User {
return create(Data.ServerInfo_UserSchema, {

View file

@ -57,6 +57,7 @@
"eslint": "^10.2.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"fake-indexeddb": "^6.2.5",
"fs-extra": "^11.3.4",
"globals": "^17.5.0",
"husky": "^9.1.7",
@ -3372,6 +3373,16 @@
"node": ">=12.0.0"
}
},
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",

View file

@ -71,6 +71,7 @@
"eslint": "^10.2.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"fake-indexeddb": "^6.2.5",
"fs-extra": "^11.3.4",
"globals": "^17.5.0",
"husky": "^9.1.7",

View file

@ -14,12 +14,17 @@ import { actionReducer } from '../store/actions';
import { ToastProvider } from '../components/Toast/ToastContext';
import type { RootState } from '../store/store';
// Minimal i18n instance for tests — returns keys as-is.
// Minimal i18n instance for tests — returns keys as-is. A non-empty
// `resources` entry is required so i18next registers `en-US` as a known
// language; otherwise `i18n.resolvedLanguage` stays `undefined`, which
// LanguageDropdown seeds into a MUI Select and MUI warns "out-of-range
// value `undefined`". Value is an empty translation map, since tests
// already assert on i18n keys directly.
const testI18n = i18n.createInstance();
testI18n.use(initReactI18next).init({
lng: 'en',
resources: {},
fallbackLng: 'en',
lng: 'en-US',
resources: { 'en-US': { translation: {} } },
fallbackLng: 'en-US',
interpolation: { escapeValue: false },
});

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import { Select, MenuItem } from '@mui/material';
@ -13,10 +13,9 @@ import AddIcon from '@mui/icons-material/Add';
import EditRoundedIcon from '@mui/icons-material/Edit';
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
import { useWebClient } from '@app/hooks';
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { KnownHostDialog } from '@app/dialogs';
import { useReduxEffect } from '@app/hooks';
import { DefaultHosts, HostDTO, getHostPort } from '@app/services';
import { getHostPort, HostDTO } from '@app/services';
import { ServerTypes } from '@app/store';
import { App } from '@app/types';
import Toast from '../Toast/Toast';
@ -32,244 +31,215 @@ enum TestConnection {
const PREFIX = 'KnownHosts';
const classes = {
root: `${PREFIX}-root`
root: `${PREFIX}-root`,
};
const Root = styled('div')(({ theme }) => ({
[`&.${classes.root}`]: {
'& .KnownHosts-error': {
color: theme.palette.error.main
color: theme.palette.error.main,
},
'& .KnownHosts-warning': {
color: theme.palette.warning.main
color: theme.palette.warning.main,
},
'& .KnownHosts-item': {
[`& .${TestConnection.TESTING}`]: {
color: theme.palette.warning.main
color: theme.palette.warning.main,
},
[`& .${TestConnection.FAILED}`]: {
color: theme.palette.error.main
color: theme.palette.error.main,
},
[`& .${TestConnection.SUCCESS}`]: {
color: theme.palette.success.main
}
}
}
color: theme.palette.success.main,
},
},
},
}));
const KnownHosts = (props) => {
const { input: { onChange }, meta, disabled } = props;
const KnownHosts = (props: any) => {
const { input, meta, disabled } = props;
const onChange: (value: HostDTO) => void = input.onChange;
const { touched, error, warning } = meta;
const { t } = useTranslation();
const webClient = useWebClient();
const knownHosts = useKnownHosts();
const [hostsState, setHostsState] = useState({
hosts: [],
selectedHost: {} as any,
});
const [dialogState, setDialogState] = useState({
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
open: false,
edit: null,
});
const [testingConnection, setTestingConnection] = useState<TestConnection>(null);
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
const [showCreateToast, setShowCreateToast] = useState(false);
const [showDeleteToast, setShowDeleteToast] = useState(false);
const [showEditToast, setShowEditToast] = useState(false);
const loadKnownHosts = useCallback(async () => {
const hosts = await HostDTO.getAll();
const selectedHost =
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
if (!hosts?.length) {
// @TODO: find a better pattern to seeding default data in indexedDB
await HostDTO.bulkAdd(DefaultHosts);
loadKnownHosts();
} else {
const selectedHost = hosts.find(({ lastSelected }) => lastSelected) || hosts[0];
setHostsState(s => ({ ...s, hosts, selectedHost }));
}
}, []);
const testConnection = (host: HostDTO) => {
setTestingConnection(TestConnection.TESTING);
webClient.request.authentication.testConnection({ ...getHostPort(host) });
};
// Mirror the store's selectedHost into the form field. Also kick off a
// connection test so the user sees the green/red indicator on mount.
useEffect(() => {
loadKnownHosts();
}, [loadKnownHosts]);
useEffect(() => {
const { selectedHost } = hostsState;
if (selectedHost?.id) {
updateLastSelectedHost(selectedHost.id).then(() => {
onChange(selectedHost);
});
if (!selectedHost) {
return;
}
}, [hostsState, onChange]);
onChange(selectedHost);
testConnection(selectedHost);
}, [selectedHost]);
useReduxEffect(() => {
setTestingConnection(TestConnection.SUCCESS);
}, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []);
useReduxEffect(
() => {
setTestingConnection(TestConnection.SUCCESS);
},
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
[]
);
useReduxEffect(() => {
setTestingConnection(TestConnection.FAILED);
}, ServerTypes.TEST_CONNECTION_FAILED, []);
useReduxEffect(
() => {
setTestingConnection(TestConnection.FAILED);
},
ServerTypes.TEST_CONNECTION_FAILED,
[]
);
const selectHost = (selectedHost) => {
setHostsState(s => ({ ...s, selectedHost }));
const onPick = async (host: HostDTO) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
onChange(host);
await knownHosts.select(host.id!);
testConnection(host);
};
const openAddKnownHostDialog = () => {
setDialogState(s => ({ ...s, open: true, edit: null }));
setDialogState((s) => ({ ...s, open: true, edit: null }));
};
const openEditKnownHostDialog = (host: HostDTO) => {
setDialogState(s => ({ ...s, open: true, edit: host }));
setDialogState((s) => ({ ...s, open: true, edit: host }));
};
const closeKnownHostDialog = () => {
setDialogState(s => ({ ...s, open: false }));
}
const handleDialogRemove = async ({ id }) => {
setHostsState(s => ({
...s,
hosts: s.hosts.filter(host => host.id !== id),
selectedHost: s.selectedHost.id === id ? s.hosts[0] : s.selectedHost,
}));
closeKnownHostDialog();
HostDTO.delete(id);
setShowDeleteToast(true)
setDialogState((s) => ({ ...s, open: false }));
};
const handleDialogSubmit = async ({ id, name, host, port }) => {
if (id) {
const hostDTO = await HostDTO.get(id);
hostDTO.name = name;
hostDTO.host = host;
hostDTO.port = port;
await hostDTO.save();
const handleDialogRemove = async ({ id }: { id: number }) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
await knownHosts.remove(id);
closeKnownHostDialog();
setShowDeleteToast(true);
};
setHostsState(s => ({
...s,
hosts: s.hosts.map(h => h.id === id ? hostDTO : h),
selectedHost: hostDTO
}));
setShowEditToast(true)
const handleDialogSubmit = async ({
id,
name,
host,
port,
}: {
id?: number;
name: string;
host: string;
port: string;
}) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
if (id) {
await knownHosts.update(id, { name, host, port });
setShowEditToast(true);
} else {
const newHost: App.Host = { name, host, port, editable: true };
newHost.id = await HostDTO.add(newHost) as number;
setHostsState(s => ({
...s,
hosts: [...s.hosts, newHost],
selectedHost: newHost,
}));
setShowCreateToast(true)
await knownHosts.add(newHost);
setShowCreateToast(true);
}
closeKnownHostDialog();
};
const updateLastSelectedHost = (hostId): Promise<any[]> => {
testConnection();
return HostDTO.getAll().then(hosts =>
hosts.map(async host => {
if (host.id === hostId) {
host.lastSelected = true;
return await host.save();
}
if (host.lastSelected) {
host.lastSelected = false;
return await host.save();
}
return host;
})
);
};
const testConnection = () => {
setTestingConnection(TestConnection.TESTING);
const options = { ...getHostPort(hostsState.selectedHost) };
webClient.request.authentication.testConnection(options);
}
return (
<Root className={'KnownHosts ' + classes.root}>
<FormControl className='KnownHosts-form' size='small' variant='outlined'>
{ touched && (
<div className='KnownHosts-validation'>
{
(error &&
<div className='KnownHosts-error'>
{error}
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
</div>
) ||
(warning && <div className='KnownHosts-warning'>{warning}</div>)
}
<FormControl className="KnownHosts-form" size="small" variant="outlined">
{touched && (
<div className="KnownHosts-validation">
{(error && (
<div className="KnownHosts-error">
{error}
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
</div>
)) ||
(warning && <div className="KnownHosts-warning">{warning}</div>)}
</div>
) }
)}
<InputLabel id='KnownHosts-select'>{ t('KnownHosts.label') }</InputLabel>
<InputLabel id="KnownHosts-select">{t('KnownHosts.label')}</InputLabel>
<Select
id='KnownHosts-select'
labelId='KnownHosts-label'
label='Host'
margin='dense'
name='host'
value={hostsState.selectedHost}
id="KnownHosts-select"
labelId="KnownHosts-label"
label="Host"
margin="dense"
name="host"
value={selectedHost ?? ''}
fullWidth={true}
onChange={e => selectHost(e.target.value)}
onChange={(e) => onPick(e.target.value as unknown as HostDTO)}
disabled={disabled}
>
<Button value={hostsState.selectedHost} onClick={openAddKnownHostDialog}>
<span>{ t('KnownHosts.add') }</span>
<AddIcon fontSize='small' color='primary' />
<Button value={selectedHost} onClick={openAddKnownHostDialog}>
<span>{t('KnownHosts.add')}</span>
<AddIcon fontSize="small" color="primary" />
</Button>
{
hostsState.hosts.map((host, index) => {
const hostPort = getHostPort(hostsState.hosts[index]);
{hosts.map((host, index) => {
const hostPort = getHostPort(host);
return (
<MenuItem value={host} key={index}>
<div className='KnownHosts-item'>
<div className='KnownHosts-item__wrapper'>
<div className={'KnownHosts-item__status ' + testingConnection}>
{
testingConnection === TestConnection.FAILED
? <PortableWifiOffIcon fontSize="small" />
: <WifiTetheringIcon fontSize="small" />
}
</div>
<div className='KnownHosts-item__label'>
<Check />
<span>{host.name} ({ hostPort.host }:{hostPort.port})</span>
</div>
return (
<MenuItem value={host as any} key={host.id ?? index}>
<div className="KnownHosts-item">
<div className="KnownHosts-item__wrapper">
<div className={'KnownHosts-item__status ' + testingConnection}>
{testingConnection === TestConnection.FAILED ? (
<PortableWifiOffIcon fontSize="small" />
) : (
<WifiTetheringIcon fontSize="small" />
)}
</div>
{ host.editable && (
<IconButton className='KnownHosts-item__edit' size='small' color='primary' onClick={() => {
openEditKnownHostDialog(hostsState.hosts[index]);
}}>
<EditRoundedIcon fontSize='small' />
</IconButton>
) }
<div className="KnownHosts-item__label">
<Check />
<span>
{host.name} ({hostPort.host}:{hostPort.port})
</span>
</div>
</div>
</MenuItem>
);
})
}
{host.editable && (
<IconButton
className="KnownHosts-item__edit"
size="small"
color="primary"
onClick={() => {
openEditKnownHostDialog(host);
}}
>
<EditRoundedIcon fontSize="small" />
</IconButton>
)}
</div>
</MenuItem>
);
})}
</Select>
</FormControl>
@ -280,9 +250,15 @@ const KnownHosts = (props) => {
onSubmit={handleDialogSubmit}
handleClose={closeKnownHostDialog}
/>
<Toast open={showCreateToast} onClose={() => setShowCreateToast(false)}>{ t('KnownHosts.toast', { mode: 'created' }) }</Toast>
<Toast open={showDeleteToast} onClose={() => setShowDeleteToast(false)}>{ t('KnownHosts.toast', { mode: 'deleted' }) }</Toast>
<Toast open={showEditToast} onClose={() => setShowEditToast(false)}>{ t('KnownHosts.toast', { mode: 'edited' }) }</Toast>
<Toast open={showCreateToast} onClose={() => setShowCreateToast(false)}>
{t('KnownHosts.toast', { mode: 'created' })}
</Toast>
<Toast open={showDeleteToast} onClose={() => setShowDeleteToast(false)}>
{t('KnownHosts.toast', { mode: 'deleted' })}
</Toast>
<Toast open={showEditToast} onClose={() => setShowEditToast(false)}>
{t('KnownHosts.toast', { mode: 'edited' })}
</Toast>
</Root>
);
};

View file

@ -11,7 +11,12 @@ import './LanguageDropdown.css';
const LanguageDropdown = () => {
const { t, i18n } = useTranslation();
const [language, setLanguage] = useState(i18n.resolvedLanguage);
// `resolvedLanguage` can be undefined when i18next hasn't matched the
// active lng against any registered resource yet — most often at the
// first render in tests with a minimal i18n instance. Fall back to
// `i18n.language` (always set to whatever was passed to init) and then
// to empty string so MUI's Select has a concrete, in-range value.
const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? '');
useEffect(() => {
if (language !== i18n.resolvedLanguage) {

View file

@ -0,0 +1,225 @@
/**
* Login auto-connect integration tests.
*
* Exercises the full wire from `useAutoLogin` through the Login container
* into `webClient.request.authentication.login`. Scenarios mirror the user-
* visible cycles we care about:
* - cold start with / without auto-connect
* - logout within the same session must NOT re-auto-connect
* - page refresh (fresh JS context) resets the gate
*
* The startup-check gate lives on the `autoLoginSession` object exported by
* `useAutoLogin.ts`. Tests flip it back to false in `beforeEach` to stand in
* for a page refresh between scenarios. `vi.resetModules()` would be the
* natural equivalent but is prohibitively slow in the full suite because it
* forces every imported module to re-evaluate.
*/
import { act, waitFor } from '@testing-library/react';
import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__';
// Lets pending microtasks resolve inside an act() scope so that the state
// updates they trigger (useFormState subscribers, useFireOnce state, etc.)
// are captured. Without this, useAutoLogin's Promise.all resolves *after*
// render returns, and React warns "update ... was not wrapped in act".
const flushEffects = async (): Promise<void> => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
import { makeSettings, makeSettingsHook } from '../../hooks/__mocks__/useSettings';
import { makeHost, makeKnownHostsHook } from '../../hooks/__mocks__/useKnownHosts';
import { autoLoginSession } from '../../hooks/useAutoLogin';
import { LoadingState } from '@app/hooks';
import Login from './Login';
const hoisted = vi.hoisted(() => ({
mockWebClient: undefined as any,
getSettings: vi.fn(),
getKnownHosts: vi.fn(),
useSettings: vi.fn(),
useKnownHosts: vi.fn(),
}));
vi.mock('../../hooks/useSettings', () => ({
useSettings: hoisted.useSettings,
getSettings: hoisted.getSettings,
}));
vi.mock('../../hooks/useKnownHosts', () => ({
useKnownHosts: hoisted.useKnownHosts,
getKnownHosts: hoisted.getKnownHosts,
}));
vi.mock('../../hooks/useWebClient', () => ({
useWebClient: () => hoisted.mockWebClient,
WebClientProvider: ({ children }: { children: any }) => children,
}));
beforeAll(() => {
const client = createMockWebClient();
(client.request.authentication as any).testConnection = vi.fn();
hoisted.mockWebClient = client;
});
afterEach(async () => {
// Absorb any state updates that lingered past the test body (e.g.
// useAutoLogin's Promise.all resolving a moment too late) so they're
// wrapped in act and don't trip React's warning during teardown.
await flushEffects();
});
beforeEach(() => {
// "Page refresh" between tests: reset the session gate that useAutoLogin
// uses to prevent re-firing within a JS session. Production code only
// writes this flag once, from inside the startup effect; tests flip it
// back to false here to simulate a fresh browser tab.
autoLoginSession.startupCheckRan = false;
// clearAllMocks in the global afterEach only clears call history; mock
// implementations (mockResolvedValue, mockReturnValue) persist. Reset
// them explicitly so a previous test's arming doesn't leak into this one.
hoisted.getSettings.mockReset();
hoisted.getKnownHosts.mockReset();
hoisted.useSettings.mockReset();
hoisted.useKnownHosts.mockReset();
const defaultHost = makeHost({
id: 1,
remember: true,
userName: 'alice',
hashedPassword: 'stored-hash',
lastSelected: true,
});
hoisted.useSettings.mockReturnValue(
makeSettingsHook({
status: LoadingState.READY,
value: makeSettings({ autoConnect: false }),
update: vi.fn().mockResolvedValue(undefined),
})
);
hoisted.useKnownHosts.mockReturnValue(
makeKnownHostsHook({
status: LoadingState.READY,
value: { hosts: [defaultHost], selectedHost: defaultHost },
})
);
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: false }));
hoisted.getKnownHosts.mockResolvedValue({
hosts: [defaultHost],
selectedHost: defaultHost,
});
});
const armAutoConnect = () => {
const host = makeHost({
id: 1,
remember: true,
userName: 'alice',
hashedPassword: 'stored-hash',
lastSelected: true,
});
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true }));
hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host });
};
describe('Login — auto-connect cold start', () => {
test('fires login when settings + host say go', async () => {
armAutoConnect();
renderWithProviders(<Login />, { preloadedState: disconnectedState });
await waitFor(() => {
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
});
expect(hoisted.mockWebClient.request.authentication.login.mock.calls[0][0]).toMatchObject({
userName: 'alice',
hashedPassword: 'stored-hash',
});
});
test('does not fire when autoConnect setting is off', async () => {
renderWithProviders(<Login />, { preloadedState: disconnectedState });
await flushEffects();
expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled();
});
test('does not fire when selected host has no stored credentials', async () => {
const host = makeHost({
id: 1,
remember: false,
userName: undefined,
hashedPassword: undefined,
lastSelected: true,
});
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true }));
hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host });
renderWithProviders(<Login />, { preloadedState: disconnectedState });
await flushEffects();
expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled();
});
});
describe('Login — logout cycle (same JS session)', () => {
test('does not re-auto-connect after first auto-login + logout', async () => {
armAutoConnect();
// First mount: Login appears, useAutoLogin fires login.
const first = renderWithProviders(<Login />, { preloadedState: disconnectedState });
await waitFor(() => {
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
});
// Simulate arriving at /server and then logging out: Login unmounts,
// then a fresh Login mounts again with disconnected state.
first.unmount();
renderWithProviders(<Login />, { preloadedState: disconnectedState });
await flushEffects();
// No second login call — the session gate is latched.
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
});
test('does not auto-connect when user enabled autoConnect mid-session and then logged out', async () => {
// Scenario: user manually logs in with autoConnect=false. They tick the
// auto-connect checkbox during that session (the setting flips true).
// They log out. Returning to /login must NOT auto-connect — the setting
// change was a preference for NEXT launch, not a signal to log in.
// First mount: autoConnect=false, so the startup check runs and finds
// nothing to do. The gate latches anyway.
const first = renderWithProviders(<Login />, { preloadedState: disconnectedState });
await flushEffects();
expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled();
first.unmount();
// Mid-session, user ticked the checkbox. Future getSettings resolves
// return the new value, but the session gate prevents a re-check.
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true }));
renderWithProviders(<Login />, { preloadedState: disconnectedState });
await flushEffects();
expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled();
});
});
describe('Login — refresh cycle', () => {
// `beforeEach` flips autoLoginSession.startupCheckRan back to false, which
// stands in for a page refresh. This test just re-asserts the positive
// case: a refresh re-enables auto-connect when the persisted preference
// still says yes.
test('a fresh session gate re-fires auto-login when conditions still hold', async () => {
armAutoConnect();
renderWithProviders(<Login />, { preloadedState: disconnectedState });
await waitFor(() => {
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
@ -9,9 +9,9 @@ import Typography from '@mui/material/Typography';
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs';
import { LanguageDropdown } from '@app/components';
import { LoginForm } from '@app/forms';
import { useReduxEffect, useFireOnce, useWebClient } from '@app/hooks';
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { Images } from '@app/images';
import { HostDTO, getHostPort, serverProps } from '@app/services';
import { getHostPort, serverProps } from '@app/services';
import { App, Enriched } from '@app/types';
import { ServerSelectors, ServerTypes } from '@app/store';
import Layout from '../Layout/Layout';
@ -66,12 +66,14 @@ const Root = styled('div')(({ theme }) => ({
const Login = () => {
const description = useAppSelector(s => ServerSelectors.getDescription(s));
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade);
const webClient = useWebClient();
const { t } = useTranslation();
const [pendingActivationOptions, setPendingActivationOptions] = useState<Enriched.PendingActivationContext | null>(null);
const [rememberLogin, setRememberLogin] = useState(null);
const rememberLoginRef = useRef<any>(null);
const knownHosts = useKnownHosts();
const [dialogState, setDialogState] = useState({
passwordResetRequestDialog: false,
resetPasswordDialog: false,
@ -113,15 +115,17 @@ const Login = () => {
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
updateHost(hashedPassword, rememberLogin);
}, ServerTypes.LOGIN_SUCCESSFUL, [rememberLogin]);
if (rememberLoginRef.current) {
updateHost(hashedPassword, rememberLoginRef.current);
}
}, ServerTypes.LOGIN_SUCCESSFUL, []);
const showDescription = () => {
return !isConnected && description?.length;
};
const onSubmitLogin = useCallback((loginForm) => {
setRememberLogin(loginForm);
rememberLoginRef.current = loginForm;
const { userName, password, selectedHost, remember } = loginForm;
const options: Omit<Enriched.LoginConnectOptions, 'reason'> = {
@ -139,18 +143,18 @@ const Login = () => {
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
const updateHost = (hashedPassword, { selectedHost, remember, userName }) => {
HostDTO.get(selectedHost.id).then(hostDTO => {
hostDTO.remember = remember;
hostDTO.userName = remember ? userName : null;
hostDTO.hashedPassword = remember ? hashedPassword : null;
useAutoLogin(handleLogin, connectionAttemptMade);
hostDTO.save();
const updateHost = (hashedPassword, { selectedHost, remember, userName }) => {
knownHosts.update(selectedHost.id, {
remember,
userName: remember ? userName : null,
hashedPassword: remember ? hashedPassword : null,
});
};
const handleRegistrationDialogSubmit = (registerForm) => {
setRememberLogin(registerForm);
rememberLoginRef.current = registerForm;
const { userName, password, email, country, realName, selectedHost } = registerForm;
webClient.request.authentication.register({

View file

@ -0,0 +1,100 @@
import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__';
import { makeSettingsHook, makeSettings } from '../../hooks/__mocks__/useSettings';
import { makeKnownHostsHook, makeHost } from '../../hooks/__mocks__/useKnownHosts';
const hoisted = vi.hoisted(() => ({
mockWebClient: undefined as any,
mockUseSettings: vi.fn(),
mockUseKnownHosts: vi.fn(),
}));
vi.mock('@app/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@app/hooks')>();
return {
...actual,
useWebClient: () => hoisted.mockWebClient,
useSettings: hoisted.mockUseSettings,
useKnownHosts: hoisted.mockUseKnownHosts,
};
});
import LoginForm from './LoginForm';
import { LoadingState } from '@app/hooks';
beforeAll(() => {
const client = createMockWebClient();
(client.request.authentication as any).testConnection = vi.fn();
hoisted.mockWebClient = client;
});
describe('LoginForm — regression: settings.autoConnect is not clobbered by host state', () => {
test('selecting a host with remember=false does NOT call settings.update', () => {
const update = vi.fn().mockResolvedValue(undefined);
hoisted.mockUseSettings.mockReturnValue(
makeSettingsHook({
status: LoadingState.READY,
value: makeSettings({ autoConnect: true }),
update,
})
);
const host = makeHost({
id: 1,
remember: false,
userName: undefined,
hashedPassword: undefined,
lastSelected: true,
});
hoisted.mockUseKnownHosts.mockReturnValue(
makeKnownHostsHook({
status: LoadingState.READY,
value: { hosts: [host], selectedHost: host },
})
);
renderWithProviders(
<LoginForm onSubmit={vi.fn()} disableSubmitButton={false} onResetPassword={vi.fn()} />,
{ preloadedState: disconnectedState }
);
// After mount + all host-sync effects settle, the form has updated its
// local fields to reflect the selected host. What MUST NOT happen is a
// write to the persisted autoConnect setting.
expect(update).not.toHaveBeenCalled();
});
test('auto-login never fires from the form; that is now the container concern', () => {
const onSubmit = vi.fn();
const update = vi.fn().mockResolvedValue(undefined);
hoisted.mockUseSettings.mockReturnValue(
makeSettingsHook({
status: LoadingState.READY,
value: makeSettings({ autoConnect: true }),
update,
})
);
const host = makeHost({
id: 1,
remember: true,
userName: 'joe',
hashedPassword: 'abc',
lastSelected: true,
});
hoisted.mockUseKnownHosts.mockReturnValue(
makeKnownHostsHook({
status: LoadingState.READY,
value: { hosts: [host], selectedHost: host },
})
);
renderWithProviders(
<LoginForm onSubmit={onSubmit} disableSubmitButton={false} onResetPassword={vi.fn()} />,
{ preloadedState: disconnectedState }
);
expect(onSubmit).not.toHaveBeenCalled();
});
});

View file

@ -1,29 +1,192 @@
import React, { useEffect, useState } from 'react';
import { Form, Field } from 'react-final-form';
import { Form, Field, useFormState, FormApi } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import { CheckboxField, InputField, KnownHosts } from '@app/components';
import { useAutoConnect } from '@app/hooks';
import { HostDTO, SettingDTO } from '@app/services';
import { App } from '@app/types';
import { useAppSelector, ServerSelectors } from '@app/store';
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
import './LoginForm.css';
const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginFormProps) => {
interface LoginFormProps {
onSubmit: (values: any) => void;
disableSubmitButton: boolean;
onResetPassword: () => void;
}
interface LoginFormBodyProps extends LoginFormProps {
form: FormApi;
handleSubmit: (event?: React.SyntheticEvent) => void;
}
const LoginFormBody = ({
form,
handleSubmit,
disableSubmitButton,
onResetPassword,
}: LoginFormBodyProps) => {
const { t } = useTranslation();
const PASSWORD_LABEL = t('Common.label.password');
const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`;
const [host, setHost] = useState(null);
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
const [autoConnect, setAutoConnect] = useAutoConnect();
const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade);
const settings = useSettings();
const hosts = useKnownHosts();
const { values } = useFormState();
const validate = values => {
const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined;
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
const [storedHashInvalidated, setStoredHashInvalidated] = useState(false);
const canUseStoredPassword = (remember: boolean, password: string | undefined) =>
Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated);
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
// Host-sync: when the selected host changes, mirror its username + stored-
// password hint into the form. Deliberately does NOT touch autoConnect — the
// persisted setting is decoupled from which host is currently picked.
useEffect(() => {
if (!selectedHost) {
return;
}
form.change('userName', selectedHost.userName);
form.change('password', '');
form.change('remember', Boolean(selectedHost.remember));
setStoredHashInvalidated(false);
togglePasswordLabel(
Boolean(selectedHost.remember && selectedHost.hashedPassword)
);
}, [selectedHost, form]);
// Mirror the persisted autoConnect setting into the form checkbox so the
// field reflects truth as soon as settings load.
useEffect(() => {
if (settings.status !== LoadingState.READY) {
return;
}
form.change('autoConnect', settings.value?.autoConnect);
}, [settings, form]);
const onUserNameChange = (userName: string | undefined) => {
const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase();
if (canUseStoredPassword(values.remember, values.password) && fieldChanged) {
setStoredHashInvalidated(true);
}
};
const onRememberChange = (checked: boolean) => {
// When the user unchecks "remember password", the auto-connect checkbox
// can't meaningfully stay on (there are no saved credentials to use), so
// reflect that in the form UI. Note: this writes only to the form field,
// NOT to the persisted setting — toggling host-level remember is not a
// user intent to change the app-level auto-connect preference.
if (!checked && values.autoConnect) {
form.change('autoConnect', false);
}
togglePasswordLabel(canUseStoredPassword(checked, values.password));
};
// User-initiated toggle of the auto-connect checkbox. This is the ONLY path
// that writes to the persisted setting — wired directly to the Checkbox's
// native onChange (see JSX below), not to a <OnChange> listener, because
// OnChange fires on programmatic form.change calls too (host-sync effects
// etc.) and would leak those into Dexie.
const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => {
fieldOnChange(checked);
if (settings.status === LoadingState.READY) {
void settings.update({ autoConnect: checked });
}
if (checked && !values.remember) {
form.change('remember', true);
}
};
return (
<form className="loginForm" onSubmit={handleSubmit}>
<div className="loginForm-items">
<div className="loginForm-item">
<Field
label={t('Common.label.username')}
name="userName"
component={InputField}
autoComplete="username"
/>
<OnChange name="userName">{onUserNameChange}</OnChange>
</div>
<div className="loginForm-item">
<Field
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
onFocus={() => setUseStoredPasswordLabel(false)}
onBlur={() =>
togglePasswordLabel(canUseStoredPassword(values.remember, values.password))
}
name="password"
type="password"
component={InputField}
autoComplete="new-password"
/>
</div>
<div className="loginForm-actions">
<Field
label={t('LoginForm.label.savePassword')}
name="remember"
component={CheckboxField}
/>
<OnChange name="remember">{onRememberChange}</OnChange>
<Button color="primary" onClick={onResetPassword}>
{t('LoginForm.label.forgot')}
</Button>
</div>
<div className="loginForm-item">
<Field name="selectedHost" component={KnownHosts} />
</div>
<div className="loginForm-actions">
<Field name="autoConnect" type="checkbox">
{({ input }) => (
<FormControlLabel
className="checkbox-field"
label={t('LoginForm.label.autoConnect')}
control={
<Checkbox
className="checkbox-field__box"
checked={!!input.value}
onChange={(_e, checked) => onUserToggleAutoConnect(checked, input.onChange)}
color="primary"
/>
}
/>
)}
</Field>
</div>
</div>
<Button
className="loginForm-submit rounded tall"
color="primary"
variant="contained"
type="submit"
disabled={disableSubmitButton}
>
{t('LoginForm.label.login')}
</Button>
</form>
);
};
const LoginForm = (props: LoginFormProps) => {
const { t } = useTranslation();
const validate = (values: any) => {
const errors: any = {};
if (!values.userName) {
@ -34,137 +197,20 @@ const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginForm
}
return errors;
}
const useStoredPassword = (remember, password) => remember && host?.hashedPassword && !password;
const togglePasswordLabel = (useStoredLabel) => {
setUseStoredPasswordLabel(useStoredLabel);
};
const handleOnSubmit = ({ userName, ...values }) => {
const handleOnSubmit = ({ userName, ...values }: any) => {
userName = userName?.trim();
onSubmit({ userName, ...values });
}
props.onSubmit({ userName, ...values });
};
return (
<Form onSubmit={handleOnSubmit} validate={validate}>
{({ handleSubmit, form }) => {
const { values } = form.getState();
useEffect(() => {
SettingDTO.get(App.APP_USER).then((userSetting: SettingDTO) => {
if (userSetting?.autoConnect && !connectionAttemptMade) {
HostDTO.getAll().then(hosts => {
let lastSelectedHost = hosts.find(({ lastSelected }) => lastSelected);
if (lastSelectedHost?.remember && lastSelectedHost?.hashedPassword) {
togglePasswordLabel(true);
form.change('selectedHost', lastSelectedHost);
form.change('userName', lastSelectedHost.userName);
form.change('remember', true);
form.submit();
}
});
}
});
}, []);
useEffect(() => {
if (!host) {
return;
}
form.change('userName', host.userName);
form.change('password', '');
onRememberChange(host.remember);
onAutoConnectChange(host.remember && autoConnect);
togglePasswordLabel(useStoredPassword(host.remember, values.password));
}, [host]);
const onUserNameChange = (userName) => {
const fieldChanged = host?.userName?.toLowerCase() !== values.userName?.toLowerCase();
if (useStoredPassword(values.remember, values.password) && fieldChanged) {
setHost(({ hashedPassword: _hashedPassword, ...s }) => ({ ...s, userName }));
}
}
const onRememberChange = (checked) => {
form.change('remember', checked);
if (!checked && values.autoConnect) {
onAutoConnectChange(false);
}
togglePasswordLabel(useStoredPassword(checked, values.password));
}
const onAutoConnectChange = (checked) => {
setAutoConnect(checked);
form.change('autoConnect', checked);
if (checked && !values.remember) {
form.change('remember', true);
}
}
return (
<form className='loginForm' onSubmit={handleSubmit}>
<div className='loginForm-items'>
<div className='loginForm-item'>
<Field label={t('Common.label.username')} name='userName' component={InputField} autoComplete='username' />
<OnChange name="userName">{onUserNameChange}</OnChange>
</div>
<div className='loginForm-item'>
<Field
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
onFocus={() => setUseStoredPasswordLabel(false)}
onBlur={() => togglePasswordLabel(useStoredPassword(values.remember, values.password))}
name='password'
type='password'
component={InputField}
autoComplete='new-password'
/>
</div>
<div className='loginForm-actions'>
<Field label={t('LoginForm.label.savePassword')} name='remember' component={CheckboxField} />
<OnChange name="remember">{onRememberChange}</OnChange>
<Button color='primary' onClick={onResetPassword}>
{ t('LoginForm.label.forgot') }
</Button>
</div>
<div className='loginForm-item'>
<Field name='selectedHost' component={KnownHosts} />
<OnChange name="selectedHost">{setHost}</OnChange>
</div>
<div className='loginForm-actions'>
<Field label={t('LoginForm.label.autoConnect')} name='autoConnect' component={CheckboxField} />
<OnChange name="autoConnect">{onAutoConnectChange}</OnChange>
</div>
</div>
<Button
className='loginForm-submit rounded tall'
color='primary'
variant='contained'
type='submit'
disabled={disableSubmitButton}
>
{ t('LoginForm.label.login') }
</Button>
</form>
)
}}
{({ handleSubmit, form }) => (
<LoginFormBody {...props} form={form} handleSubmit={handleSubmit} />
)}
</Form>
);
};
interface LoginFormProps {
onSubmit: any;
disableSubmitButton: boolean,
onResetPassword: any;
}
export default LoginForm;

View file

@ -0,0 +1,40 @@
import type { HostDTO } from '@app/services';
import { LoadingState } from '../useSharedStore';
import type { KnownHostsHook, KnownHostsValue } from '../useKnownHosts';
export const makeHost = (overrides: Partial<HostDTO> = {}): HostDTO =>
({
id: 1,
name: 'Test Host',
host: 'test.example',
port: '4747',
editable: false,
lastSelected: true,
userName: undefined,
hashedPassword: undefined,
remember: false,
save: vi.fn(),
...overrides,
}) as unknown as HostDTO;
export const makeKnownHostsValue = (overrides: Partial<KnownHostsValue> = {}): KnownHostsValue => {
const host = makeHost();
return { hosts: [host], selectedHost: host, ...overrides };
};
export const makeKnownHostsHook = (overrides: Partial<KnownHostsHook> = {}): KnownHostsHook =>
({
status: LoadingState.READY,
value: makeKnownHostsValue(),
select: vi.fn().mockResolvedValue(undefined),
add: vi.fn(),
update: vi.fn(),
remove: vi.fn().mockResolvedValue(undefined),
...overrides,
}) as KnownHostsHook;
export const useKnownHosts = vi.fn<() => KnownHostsHook>(() => makeKnownHostsHook());
export const getKnownHosts = vi.fn<() => Promise<KnownHostsValue>>(() =>
Promise.resolve(makeKnownHostsValue())
);

View file

@ -0,0 +1,20 @@
import type { SettingDTO } from '@app/services';
import { LoadingState } from '../useSharedStore';
import type { SettingsHook } from '../useSettings';
export const makeSettings = (overrides: Partial<SettingDTO> = {}): SettingDTO =>
({ user: '*app', autoConnect: false, save: vi.fn(), ...overrides }) as SettingDTO;
export const makeSettingsHook = (overrides: Partial<SettingsHook> = {}): SettingsHook =>
({
status: LoadingState.READY,
value: makeSettings(),
update: vi.fn().mockResolvedValue(undefined),
...overrides,
}) as SettingsHook;
export const useSettings = vi.fn<() => SettingsHook>(() => makeSettingsHook());
export const getSettings = vi.fn<() => Promise<SettingDTO>>(() =>
Promise.resolve(makeSettings())
);

View file

@ -1,5 +1,8 @@
export * from './useAutoConnect';
export * from './useAutoLogin';
export * from './useFireOnce';
export * from './useKnownHosts';
export * from './useLocaleSort';
export * from './useReduxEffect';
export * from './useSettings';
export * from './useSharedStore';
export * from './useWebClient';

View file

@ -1,86 +0,0 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useAutoConnect } from './useAutoConnect';
const mockSave = vi.fn();
let storedSetting: any = null;
vi.mock('@app/services', () => ({
SettingDTO: class MockSettingDTO {
user: string;
autoConnect = false;
constructor(user: string) {
this.user = user;
}
save = mockSave;
static get = vi.fn(() => Promise.resolve(storedSetting));
},
}));
vi.mock('@app/types', () => ({
App: { APP_USER: '*app' },
}));
describe('useAutoConnect', () => {
beforeEach(() => {
storedSetting = null;
mockSave.mockClear();
});
test('returns undefined initially, then resolves to stored autoConnect value', async () => {
storedSetting = { user: '*app', autoConnect: true, save: mockSave };
const { result } = renderHook(() => useAutoConnect());
expect(result.current[0]).toBeUndefined();
await waitFor(() => {
expect(result.current[0]).toBe(true);
});
});
test('creates and saves a new SettingDTO when none exists', async () => {
storedSetting = null;
const { result } = renderHook(() => useAutoConnect());
await waitFor(() => {
expect(result.current[0]).toBe(false);
});
// save() called once for the newly created setting
expect(mockSave).toHaveBeenCalledTimes(1);
});
test('persists when setAutoConnect is called', async () => {
storedSetting = { user: '*app', autoConnect: false, save: mockSave };
const { result } = renderHook(() => useAutoConnect());
await waitFor(() => {
expect(result.current[0]).toBe(false);
});
mockSave.mockClear();
act(() => {
result.current[1](true);
});
await waitFor(() => {
expect(result.current[0]).toBe(true);
});
expect(mockSave).toHaveBeenCalled();
});
test('does not save redundantly on initial mount when setting exists', async () => {
storedSetting = { user: '*app', autoConnect: true, save: mockSave };
renderHook(() => useAutoConnect());
await waitFor(() => {
expect(mockSave).not.toHaveBeenCalled();
});
});
});

View file

@ -1,33 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { SettingDTO } from '@app/services';
import { App } from '@app/types';
export function useAutoConnect(): [boolean | undefined, Dispatch<SetStateAction<boolean | undefined>>] {
const [setting, setSetting] = useState<SettingDTO | undefined>(undefined);
const [autoConnect, setAutoConnect] = useState<boolean | undefined>(undefined);
const prevAutoConnectRef = useRef<boolean | undefined>(undefined);
useEffect(() => {
SettingDTO.get(App.APP_USER).then((loaded: SettingDTO) => {
if (!loaded) {
loaded = new SettingDTO(App.APP_USER);
loaded.save();
}
setSetting(loaded);
setAutoConnect(loaded.autoConnect);
prevAutoConnectRef.current = loaded.autoConnect;
});
}, []);
useEffect(() => {
if (setting && autoConnect !== prevAutoConnectRef.current) {
prevAutoConnectRef.current = autoConnect;
setting.autoConnect = autoConnect;
setting.save();
}
}, [autoConnect]);
return [autoConnect, setAutoConnect];
}

View file

@ -0,0 +1,169 @@
import { renderHook, waitFor } from '@testing-library/react';
vi.mock('./useSettings');
vi.mock('./useKnownHosts');
type AnyRecord = Record<string, any>;
let useAutoLoginModule: typeof import('./useAutoLogin');
let getSettingsMock: any;
let getKnownHostsMock: any;
let makeSettings: (o?: AnyRecord) => AnyRecord;
let makeHost: (o?: AnyRecord) => AnyRecord;
beforeEach(async () => {
// Fresh module graph per test so the module-level hasFiredThisSession flag resets.
vi.resetModules();
useAutoLoginModule = await import('./useAutoLogin');
const settingsMockModule = await import('./__mocks__/useSettings');
const hostsMockModule = await import('./__mocks__/useKnownHosts');
getSettingsMock = settingsMockModule.getSettings;
getKnownHostsMock = hostsMockModule.getKnownHosts;
makeSettings = settingsMockModule.makeSettings as any;
makeHost = hostsMockModule.makeHost as any;
});
interface ConfigureOptions {
autoConnect?: boolean;
remember?: boolean;
hashedPassword?: string;
userName?: string;
}
const configure = ({
autoConnect = false,
remember = false,
hashedPassword = undefined,
userName = 'joe',
}: ConfigureOptions) => {
const settings = makeSettings({ autoConnect });
const host = makeHost({ remember, hashedPassword, userName, lastSelected: true });
getSettingsMock.mockResolvedValue(settings);
getKnownHostsMock.mockResolvedValue({ hosts: [host], selectedHost: host });
};
describe('useAutoLogin', () => {
test('fires onLogin when all conditions are met', async () => {
const onLogin = vi.fn();
configure({ autoConnect: true, remember: true, hashedPassword: 'hp', userName: 'joe' });
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1));
expect(onLogin.mock.calls[0][0]).toMatchObject({
userName: 'joe',
remember: true,
password: '',
});
});
test('does not fire when settings.autoConnect is false', async () => {
const onLogin = vi.fn();
configure({ autoConnect: false, remember: true, hashedPassword: 'hp' });
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
// Let the pending promise flush.
await Promise.resolve();
await Promise.resolve();
expect(onLogin).not.toHaveBeenCalled();
});
test('does not fire when host lacks remember flag', async () => {
const onLogin = vi.fn();
configure({ autoConnect: true, remember: false, hashedPassword: 'hp' });
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
await Promise.resolve();
await Promise.resolve();
expect(onLogin).not.toHaveBeenCalled();
});
test('does not fire when host lacks hashedPassword', async () => {
const onLogin = vi.fn();
configure({ autoConnect: true, remember: true, hashedPassword: undefined });
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
await Promise.resolve();
await Promise.resolve();
expect(onLogin).not.toHaveBeenCalled();
});
test('does not fire when a connection attempt is already in flight', async () => {
const onLogin = vi.fn();
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, true));
await Promise.resolve();
await Promise.resolve();
expect(onLogin).not.toHaveBeenCalled();
});
test('fires at most once per session, even across unmount + remount', async () => {
const onLogin = vi.fn();
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1));
unmount();
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
await Promise.resolve();
await Promise.resolve();
expect(onLogin).toHaveBeenCalledTimes(1);
});
test('manual login then logout does NOT auto-connect on return to /login', async () => {
// Regression: the flag tracks whether the startup check RAN, not whether
// it FIRED. Without that distinction, a first-session manual login (where
// the hook saw conditions unmet) would leave the flag unset, and the
// next mount (after logout) would find conditions met and auto-connect.
const onLogin = vi.fn();
// First mount: autoConnect=false, so the check runs but doesn't fire.
configure({ autoConnect: false, remember: true, hashedPassword: 'hp' });
const { unmount } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
await Promise.resolve();
await Promise.resolve();
expect(onLogin).not.toHaveBeenCalled();
// User logs in manually and later hits logout; Login re-mounts with
// autoConnect now flipped on (they ticked the box during the session).
unmount();
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
await Promise.resolve();
await Promise.resolve();
expect(onLogin).not.toHaveBeenCalled();
});
test('ticking the auto-connect checkbox after mount does NOT trigger a login', async () => {
// This is the specific regression: editing the persisted preference is a
// settings write, not a "log in now" signal. Because useAutoLogin reads
// via whenReady (one-shot) instead of subscribing, a subsequent settings
// change cannot re-run the orchestrator.
const onLogin = vi.fn();
configure({ autoConnect: false, remember: true, hashedPassword: 'hp' });
const { rerender } = renderHook(() => useAutoLoginModule.useAutoLogin(onLogin, false));
await Promise.resolve();
await Promise.resolve();
expect(onLogin).not.toHaveBeenCalled();
// Swap to a "settings.autoConnect=true" world and rerender. Since
// getSettings is a one-shot that already resolved with the old value,
// changing its mockResolvedValue doesn't retroactively matter.
configure({ autoConnect: true, remember: true, hashedPassword: 'hp' });
rerender();
await Promise.resolve();
await Promise.resolve();
expect(onLogin).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,76 @@
import { useEffect } from 'react';
import type { HostDTO } from '@app/services';
import { getKnownHosts } from './useKnownHosts';
import { getSettings } from './useSettings';
export interface LoginFormValues {
userName: string;
password?: string;
selectedHost: HostDTO;
remember: boolean;
autoConnect?: boolean;
}
// Auto-login is a *startup* concern — the persisted preference is consulted
// once per JS session, after both stores have loaded. A logout within the
// same session is an explicit user action; returning to /login should not
// re-auto-connect (matches Cockatrice desktop behaviour). The flag is
// module-scope so it persists across Login remounts and is naturally reset
// on page refresh, which is the one time we do want another try.
//
// The flag tracks whether the *check* has run, not whether it *fired* — a
// manual first login followed by a logout must not re-trigger auto-login
// either, so the outcome of the check is irrelevant; only that it happened.
//
// Exported as a mutable object (rather than a bare `let`) so integration
// tests can reset `startupCheckRan = false` between scenarios without
// resorting to `vi.resetModules`, which is prohibitively slow in the full
// suite. Production code only writes the flag from inside the effect.
export const autoLoginSession = { startupCheckRan: false };
// Deliberately does NOT subscribe to the settings / known-hosts stores —
// user actions that change those stores (ticking the auto-connect checkbox,
// picking a different host) are preference edits, not "log in now" signals.
export function useAutoLogin(
onLogin: (values: LoginFormValues) => void,
connectionAttemptMade: boolean,
): void {
useEffect(() => {
if (autoLoginSession.startupCheckRan) {
return;
}
if (connectionAttemptMade) {
return;
}
let cancelled = false;
Promise.all([getSettings(), getKnownHosts()]).then(([settings, hosts]) => {
if (cancelled || autoLoginSession.startupCheckRan) {
return;
}
autoLoginSession.startupCheckRan = true;
if (!settings.autoConnect) {
return;
}
const { selectedHost } = hosts;
if (!selectedHost?.remember || !selectedHost?.hashedPassword) {
return;
}
onLogin({
selectedHost,
userName: selectedHost.userName ?? '',
remember: true,
password: '',
});
});
return () => {
cancelled = true;
};
}, [connectionAttemptMade, onLogin]);
}

View file

@ -0,0 +1,211 @@
import { renderHook, act, waitFor } from '@testing-library/react';
type StoredHost = {
id?: number;
name: string;
host: string;
port: string;
editable: boolean;
lastSelected?: boolean;
userName?: string;
hashedPassword?: string;
remember?: boolean;
save?: ReturnType<typeof vi.fn>;
};
let stored: StoredHost[] = [];
let nextId = 1;
async function upsertStoredHost(this: StoredHost) {
const idx = stored.findIndex((h) => h.id === this.id);
if (idx >= 0) {
stored[idx] = this;
} else {
this.id = this.id ?? nextId++;
stored.push(this);
}
}
const mockSave = vi.fn<(self: StoredHost) => Promise<void>>(upsertStoredHost);
vi.mock('@app/services', () => ({
HostDTO: class MockHostDTO {
id?: number;
name!: string;
host!: string;
port!: string;
editable!: boolean;
lastSelected?: boolean;
userName?: string;
hashedPassword?: string;
remember?: boolean;
save = function save(this: StoredHost) {
return mockSave.call(this);
};
static getAll = vi.fn(async () => {
return stored.map((h) => {
const inst = new MockHostDTO() as unknown as StoredHost;
Object.assign(inst, h);
(inst as unknown as MockHostDTO).save = function save() {
return mockSave.call(this as unknown as StoredHost);
};
return inst;
});
});
static get = vi.fn(async (id: number) => {
const match = stored.find((h) => h.id === id);
if (!match) {
return undefined;
}
const inst = new MockHostDTO() as unknown as StoredHost;
Object.assign(inst, match);
(inst as unknown as MockHostDTO).save = function save() {
return mockSave.call(this as unknown as StoredHost);
};
return inst;
});
static add = vi.fn(async (host: StoredHost) => {
const id = nextId++;
stored.push({ ...host, id });
return id;
});
static bulkAdd = vi.fn(async (hosts: StoredHost[]) => {
for (const h of hosts) {
stored.push({ ...h, id: nextId++ });
}
});
static delete = vi.fn(async (id: number | string) => {
const numericId = typeof id === 'string' ? Number(id) : id;
stored = stored.filter((h) => h.id !== numericId);
});
},
DefaultHosts: [
{ name: 'A', host: 'a.x', port: '1', editable: false },
{ name: 'B', host: 'b.x', port: '2', editable: false },
],
}));
vi.mock('@app/types', () => ({ App: {} }));
let useKnownHostsModule: typeof import('./useKnownHosts');
let LoadingState: typeof import('./useSharedStore').LoadingState;
beforeEach(async () => {
vi.resetModules();
stored = [];
nextId = 1;
mockSave.mockClear();
useKnownHostsModule = await import('./useKnownHosts');
({ LoadingState } = await import('./useSharedStore'));
});
describe('useKnownHosts', () => {
test('seeds DefaultHosts when the DB is empty and pins hosts[0] as lastSelected', async () => {
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
await waitFor(() => {
expect(result.current.status).toBe(LoadingState.READY);
});
if (result.current.status !== LoadingState.READY) {
throw new Error('not ready');
}
expect(result.current.value.hosts).toHaveLength(2);
expect(result.current.value.selectedHost.name).toBe('A');
expect(result.current.value.selectedHost.lastSelected).toBe(true);
});
test('select(id) flips lastSelected atomically — exactly one row true', async () => {
stored = [
{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true },
{ id: 2, name: 'B', host: 'b', port: '2', editable: false, lastSelected: false },
];
nextId = 3;
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
await waitFor(() => {
expect(result.current.status).toBe(LoadingState.READY);
});
await act(async () => {
await result.current.select(2);
});
if (result.current.status !== LoadingState.READY) {
throw new Error('not ready');
}
expect(result.current.value.selectedHost.id).toBe(2);
const lastSelectedCount = result.current.value.hosts.filter((h) => h.lastSelected).length;
expect(lastSelectedCount).toBe(1);
});
test('add() persists to Dexie and mirrors the new host into in-memory state', async () => {
stored = [{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true }];
nextId = 2;
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
await waitFor(() => expect(result.current.status).toBe(LoadingState.READY));
await act(async () => {
await result.current.add({ name: 'C', host: 'c', port: '3', editable: true });
});
if (result.current.status !== LoadingState.READY) {
throw new Error('not ready');
}
expect(result.current.value.hosts).toHaveLength(2);
expect(result.current.value.hosts.some((h) => h.name === 'C')).toBe(true);
});
test('update() patches the host and replaces the snapshot reference', async () => {
stored = [
{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true, remember: false },
];
nextId = 2;
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
await waitFor(() => expect(result.current.status).toBe(LoadingState.READY));
const before = result.current;
await act(async () => {
await result.current.update(1, { remember: true, userName: 'joe' });
});
expect(result.current).not.toBe(before);
if (result.current.status !== LoadingState.READY) {
throw new Error('not ready');
}
expect(result.current.value.hosts[0].remember).toBe(true);
expect(result.current.value.hosts[0].userName).toBe('joe');
});
test('remove() deletes and picks a new selectedHost when the removed row was selected', async () => {
stored = [
{ id: 1, name: 'A', host: 'a', port: '1', editable: false, lastSelected: true },
{ id: 2, name: 'B', host: 'b', port: '2', editable: false, lastSelected: false },
];
nextId = 3;
const { result } = renderHook(() => useKnownHostsModule.useKnownHosts());
await waitFor(() => expect(result.current.status).toBe(LoadingState.READY));
await act(async () => {
await result.current.remove(1);
});
if (result.current.status !== LoadingState.READY) {
throw new Error('not ready');
}
expect(result.current.value.hosts).toHaveLength(1);
expect(result.current.value.selectedHost.id).toBe(2);
expect(result.current.value.selectedHost.lastSelected).toBe(true);
});
});

View file

@ -0,0 +1,132 @@
import { DefaultHosts, HostDTO } from '@app/services';
import { App } from '@app/types';
import { createSharedStore, Loadable, useSharedStore } from './useSharedStore';
// Shared-store scope justification: multiple components on the login screen
// read the same host list and selected host simultaneously (KnownHosts
// dropdown, LoginForm's host-sync effect, useAutoLogin, and the Login
// container's post-login write). Collapsing to useState inside one component
// would duplicate Dexie reads and race on lastSelected updates — exactly the
// bug we set out to fix.
export interface KnownHostsValue {
hosts: HostDTO[];
selectedHost: HostDTO;
}
const loadAll = async (): Promise<HostDTO[]> => {
let hosts = await HostDTO.getAll();
if (!hosts?.length) {
await HostDTO.bulkAdd(DefaultHosts);
hosts = await HostDTO.getAll();
}
return hosts;
};
const normalize = async (hosts: HostDTO[]): Promise<KnownHostsValue> => {
const existing = hosts.find((h) => h.lastSelected);
if (existing) {
return { hosts, selectedHost: existing };
}
// No row marked lastSelected yet (first-ever load after seeding, or legacy
// data). Pin hosts[0] and persist so subsequent boots are deterministic.
const selected = hosts[0];
selected.lastSelected = true;
await selected.save();
return { hosts, selectedHost: selected };
};
// Exported for integration-test reset; see settingsStore for the rationale.
export const knownHostsStore = createSharedStore<KnownHostsValue>(async () => {
const hosts = await loadAll();
return normalize(hosts);
});
const store = knownHostsStore;
export type KnownHostsHook = Loadable<KnownHostsValue> & {
select: (id: number) => Promise<void>;
add: (host: App.Host) => Promise<HostDTO>;
update: (id: number, patch: Partial<HostDTO>) => Promise<HostDTO>;
remove: (id: number) => Promise<void>;
};
// Guard for mutators. Mutators run outside React render, so we can't gate
// them through the hook's status; peek + throw is the fail-fast alternative.
const requireValue = (method: string): KnownHostsValue => {
const current = store.peek();
if (!current) {
throw new Error(`useKnownHosts.${method} called before hosts loaded`);
}
return current;
};
const select = async (id: number): Promise<void> => {
const { hosts } = requireValue('select');
const target = hosts.find((h) => h.id === id);
if (!target) {
throw new Error(`useKnownHosts.select: unknown host id ${id}`);
}
const writes: Promise<unknown>[] = [];
for (const h of hosts) {
if (h === target) {
if (!h.lastSelected) {
h.lastSelected = true;
writes.push(h.save());
}
} else if (h.lastSelected) {
h.lastSelected = false;
writes.push(h.save());
}
}
await Promise.all(writes);
store.setValue({ hosts: [...hosts], selectedHost: target });
};
const add = async (host: App.Host): Promise<HostDTO> => {
const { hosts, selectedHost } = requireValue('add');
const created = await HostDTO.get((await HostDTO.add(host)) as number);
store.setValue({ hosts: [...hosts, created], selectedHost });
return created;
};
const update = async (id: number, patch: Partial<HostDTO>): Promise<HostDTO> => {
const { hosts, selectedHost } = requireValue('update');
const existing = hosts.find((h) => h.id === id);
if (!existing) {
throw new Error(`useKnownHosts.update: unknown host id ${id}`);
}
Object.assign(existing, patch);
await existing.save();
store.setValue({
hosts: [...hosts],
selectedHost: selectedHost.id === id ? existing : selectedHost,
});
return existing;
};
const remove = async (id: number): Promise<void> => {
const { hosts, selectedHost } = requireValue('remove');
await HostDTO.delete(id as unknown as string);
const next = hosts.filter((h) => h.id !== id);
let nextSelected = selectedHost;
if (selectedHost.id === id) {
nextSelected = next[0];
if (nextSelected) {
nextSelected.lastSelected = true;
await nextSelected.save();
}
}
store.setValue({ hosts: next, selectedHost: nextSelected });
};
export function useKnownHosts(): KnownHostsHook {
const state = useSharedStore(store);
return { ...state, select, add, update, remove };
}
// Non-reactive one-shot accessor, mirroring getSettings. See the comment on
// that export in useSettings.ts for the rationale.
export const getKnownHosts = (): Promise<KnownHostsValue> => store.whenReady();

View file

@ -0,0 +1,98 @@
import { renderHook, act, waitFor } from '@testing-library/react';
const mockSave = vi.fn();
let storedSetting: any = null;
vi.mock('@app/services', () => ({
SettingDTO: class MockSettingDTO {
user: string;
autoConnect = false;
constructor(user: string) {
this.user = user;
}
save = mockSave;
static get = vi.fn(() => Promise.resolve(storedSetting));
},
}));
vi.mock('@app/types', () => ({
App: { APP_USER: '*app' },
}));
// Each spec resets module state so the shared store starts fresh.
let useSettingsModule: typeof import('./useSettings');
let LoadingState: typeof import('./useSharedStore').LoadingState;
beforeEach(async () => {
vi.resetModules();
storedSetting = null;
mockSave.mockClear();
useSettingsModule = await import('./useSettings');
({ LoadingState } = await import('./useSharedStore'));
});
describe('useSettings', () => {
test('starts in loading state, then resolves to the stored setting', async () => {
storedSetting = { user: '*app', autoConnect: true, save: mockSave };
const { result } = renderHook(() => useSettingsModule.useSettings());
expect(result.current.status).toBe(LoadingState.LOADING);
await waitFor(() => {
expect(result.current.status).toBe(LoadingState.READY);
});
if (result.current.status === LoadingState.READY) {
expect(result.current.value.autoConnect).toBe(true);
}
});
test('creates and saves a new SettingDTO when none exists', async () => {
storedSetting = null;
const { result } = renderHook(() => useSettingsModule.useSettings());
await waitFor(() => {
expect(result.current.status).toBe(LoadingState.READY);
});
if (result.current.status === LoadingState.READY) {
expect(result.current.value.autoConnect).toBe(false);
}
expect(mockSave).toHaveBeenCalledTimes(1);
});
test('update() persists the patch and re-renders with a new snapshot', async () => {
storedSetting = { user: '*app', autoConnect: false, save: mockSave };
const { result } = renderHook(() => useSettingsModule.useSettings());
await waitFor(() => {
expect(result.current.status).toBe(LoadingState.READY);
});
mockSave.mockClear();
const before = result.current;
await act(async () => {
await result.current.update({ autoConnect: true });
});
expect(result.current).not.toBe(before);
if (result.current.status === LoadingState.READY) {
expect(result.current.value.autoConnect).toBe(true);
}
expect(mockSave).toHaveBeenCalledTimes(1);
});
test('does not re-save on the initial load when setting already exists', async () => {
storedSetting = { user: '*app', autoConnect: true, save: mockSave };
renderHook(() => useSettingsModule.useSettings());
await waitFor(() => {
expect(mockSave).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,52 @@
import { SettingDTO } from '@app/services';
import { App } from '@app/types';
import { createSharedStore, Loadable, useSharedStore } from './useSharedStore';
// First-time bootstrap: SettingDTO.get returns undefined when no row exists
// for the app user yet (fresh install, or a user who has never hit the
// settings code path before). We materialize a default DTO and persist it so
// subsequent loads always see a non-null row.
//
// Exported as `settingsStore` so integration tests can call
// `settingsStore.reset()` between scenarios — the module cache would
// otherwise serve stale data across per-test Dexie resets. Production code
// goes through `useSettings()` / `getSettings()` and doesn't touch this.
export const settingsStore = createSharedStore<SettingDTO>(async () => {
let loaded = await SettingDTO.get(App.APP_USER);
if (!loaded) {
loaded = new SettingDTO(App.APP_USER);
await loaded.save();
}
return loaded;
});
const store = settingsStore;
export type SettingsHook = Loadable<SettingDTO> & {
update: (patch: Partial<SettingDTO>) => Promise<void>;
};
export function useSettings(): SettingsHook {
const state = useSharedStore(store);
const update = async (patch: Partial<SettingDTO>) => {
// Fail-fast if a caller tries to write before the initial load resolves.
// Shouldn't happen in normal flow (the checkbox is gated on the hook's
// ready status), so surface the bug loudly instead of silently no-oping.
const current = store.peek();
if (!current) {
throw new Error('useSettings.update called before settings loaded');
}
Object.assign(current, patch);
await current.save();
store.setValue(current);
};
return { ...state, update };
}
// Non-reactive one-shot accessor. Use this from code that wants the loaded
// value exactly once and does NOT want to re-run when the user subsequently
// edits their settings — e.g. the auto-login orchestrator, which consults
// the persisted preference at startup only.
export const getSettings = (): Promise<SettingDTO> => store.whenReady();

View file

@ -0,0 +1,102 @@
import { createSharedStore, LoadingState } from './useSharedStore';
describe('createSharedStore', () => {
test('starts in LOADING state before any subscriber connects', () => {
const store = createSharedStore(async () => 'value');
expect(store.getSnapshot()).toEqual({ status: LoadingState.LOADING });
expect(store.peek()).toBeUndefined();
});
test('triggers load on first subscribe and resolves to READY', async () => {
let loadCalls = 0;
const store = createSharedStore(async () => {
loadCalls++;
return 42;
});
const cb = vi.fn();
store.subscribe(cb);
await vi.waitFor(() => {
expect(store.getSnapshot()).toEqual({ status: LoadingState.READY, value: 42 });
});
expect(loadCalls).toBe(1);
expect(cb).toHaveBeenCalled();
expect(store.peek()).toBe(42);
});
test('multiple subscribers share a single load', async () => {
let loadCalls = 0;
const store = createSharedStore(async () => {
loadCalls++;
return 'shared';
});
const cb1 = vi.fn();
const cb2 = vi.fn();
store.subscribe(cb1);
store.subscribe(cb2);
await vi.waitFor(() => {
expect(store.getSnapshot()).toEqual({ status: LoadingState.READY, value: 'shared' });
});
expect(loadCalls).toBe(1);
expect(cb1).toHaveBeenCalled();
expect(cb2).toHaveBeenCalled();
});
test('setValue notifies subscribers with a new snapshot reference', async () => {
const store = createSharedStore(async () => ({ n: 1 }));
const cb = vi.fn();
store.subscribe(cb);
await vi.waitFor(() => expect(store.getSnapshot().status).toBe(LoadingState.READY));
const before = store.getSnapshot();
cb.mockClear();
store.setValue({ n: 2 });
const after = store.getSnapshot();
expect(after).not.toBe(before);
expect(after).toEqual({ status: LoadingState.READY, value: { n: 2 } });
expect(cb).toHaveBeenCalledTimes(1);
});
test('transitions to ERROR when loader rejects and notifies subscribers', async () => {
const store = createSharedStore(async () => {
throw new Error('boom');
});
const cb = vi.fn();
store.subscribe(cb);
await vi.waitFor(() => {
expect(store.getSnapshot().status).toBe(LoadingState.ERROR);
});
const snapshot = store.getSnapshot();
expect(snapshot.status).toBe(LoadingState.ERROR);
if (snapshot.status === LoadingState.ERROR) {
expect(snapshot.error.message).toBe('boom');
}
expect(cb).toHaveBeenCalled();
expect(store.peek()).toBeUndefined();
});
test('unsubscribe removes the callback', async () => {
const store = createSharedStore(async () => 'x');
const cb = vi.fn();
const unsubscribe = store.subscribe(cb);
await vi.waitFor(() => expect(store.getSnapshot().status).toBe(LoadingState.READY));
cb.mockClear();
unsubscribe();
store.setValue('y');
expect(cb).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,127 @@
import { useSyncExternalStore } from 'react';
export enum LoadingState {
LOADING = 'loading',
READY = 'ready',
ERROR = 'error',
}
export interface Loadable<T> {
status: LoadingState;
value?: T;
error?: Error;
}
export interface SharedStore<T> {
// Reactive surface: subscribe + snapshot back useSyncExternalStore so
// consuming components re-render on every store update.
subscribe: (cb: () => void) => () => void;
getSnapshot: () => Loadable<T>;
// One-shot surface: whenReady resolves with the initial loaded value and
// never fires again. Callers that only need "read once after init" (e.g.
// the auto-login orchestrator) use this to avoid subscribing to updates
// they don't care about — which would otherwise turn a user preference
// toggle into a re-evaluation of startup logic.
whenReady: () => Promise<T>;
// Mutator-side helpers, not for consumption inside render.
setValue: (value: T) => void;
peek: () => T | undefined;
// Clear cached state and the resolved readyPromise; the next subscribe /
// whenReady call triggers a fresh load. In production nobody calls this;
// integration tests use it to discard per-test Dexie state without
// paying the cost of vi.resetModules across the whole dep graph.
reset: () => void;
}
export function createSharedStore<T>(load: () => Promise<T>): SharedStore<T> {
let state: Loadable<T> = { status: LoadingState.LOADING };
const subscribers = new Set<() => void>();
let loadStarted = false;
// whenReady is lazy: we only attach a promise once someone asks for one.
// This avoids Node's unhandled-rejection bookkeeping for stores whose
// loader fails but never had a whenReady caller.
let readyPromise: Promise<T> | null = null;
const notify = () => {
for (const cb of subscribers) {
cb();
}
};
const ensureLoaded = () => {
if (loadStarted) {
return;
}
loadStarted = true;
load().then(
(value) => {
state = { status: LoadingState.READY, value };
notify();
},
(error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error));
state = { status: LoadingState.ERROR, error: err };
notify();
}
);
};
const subscribe = (cb: () => void) => {
subscribers.add(cb);
ensureLoaded();
return () => {
subscribers.delete(cb);
};
};
return {
subscribe,
getSnapshot: () => state,
whenReady: () => {
ensureLoaded();
if (!readyPromise) {
readyPromise = new Promise<T>((resolve, reject) => {
const settle = (): boolean => {
if (state.status === LoadingState.READY) {
resolve(state.value as T);
return true;
}
if (state.status === LoadingState.ERROR && state.error) {
reject(state.error);
return true;
}
return false;
};
if (settle()) {
return;
}
const unsub = subscribe(() => {
if (settle()) {
unsub();
}
});
});
}
return readyPromise;
},
setValue: (value) => {
state = { status: LoadingState.READY, value };
notify();
},
peek: () => (state.status === LoadingState.READY ? (state.value as T) : undefined),
reset: () => {
state = { status: LoadingState.LOADING };
loadStarted = false;
readyPromise = null;
notify();
},
};
}
export function useSharedStore<T>(store: SharedStore<T>): Loadable<T> {
return useSyncExternalStore(store.subscribe, store.getSnapshot);
}

View file

@ -2,7 +2,10 @@ import { createContext, useContext, useState, ReactNode } from 'react';
import { WebClient } from '@app/websocket';
import { createWebClientRequest, createWebClientResponse } from '@app/api';
const WebClientContext = createContext<WebClient | null>(null);
// Exported so integration tests can inject the WebClient singleton built
// by their shared setup without going through the production provider
// (which would attempt to `new WebClient(...)` a second time and throw).
export const WebClientContext = createContext<WebClient | null>(null);
export function WebClientProvider({ children }: { children: ReactNode }) {
const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse()));

View file

@ -12,6 +12,9 @@ export default defineConfig({
},
server: {
open: true,
watch: {
ignored: ['build', 'coverage', 'integration']
}
},
test: {
globals: true,

View file

@ -15,7 +15,11 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
setupFiles: ['./integration/src/helpers/setup.ts'],
include: ['integration/src/**/*.spec.ts'],
include: ['integration/src/**/*.spec.{ts,tsx}'],
// App-suite tests render the full Login container against real Dexie
// (fake-indexeddb) + real WebClient. Under CI/disk load the default
// 5s timeout is tight; 10s leaves headroom without masking real hangs.
testTimeout: 10000,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],