mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
refactor login flow and hooks, address autologin issues
This commit is contained in:
parent
dcd6dc00f4
commit
bd2382c94e
43 changed files with 2179 additions and 484 deletions
|
|
@ -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' },
|
||||
|
|
|
|||
33
webclient/integration/src/app/helpers.tsx
Normal file
33
webclient/integration/src/app/helpers.tsx
Normal 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 };
|
||||
192
webclient/integration/src/app/login-autoconnect.spec.tsx
Normal file
192
webclient/integration/src/app/login-autoconnect.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
91
webclient/integration/src/services/dexie/hosts.spec.ts
Normal file
91
webclient/integration/src/services/dexie/hosts.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
webclient/integration/src/services/dexie/resetDexie.ts
Normal file
12
webclient/integration/src/services/dexie/resetDexie.ts
Normal 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(),
|
||||
]);
|
||||
}
|
||||
69
webclient/integration/src/services/dexie/settings.spec.ts
Normal file
69
webclient/integration/src/services/dexie/settings.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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, {
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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(
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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<{
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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, {
|
||||
11
webclient/package-lock.json
generated
11
webclient/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
225
webclient/src/containers/Login/Login.spec.tsx
Normal file
225
webclient/src/containers/Login/Login.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
100
webclient/src/forms/LoginForm/LoginForm.spec.tsx
Normal file
100
webclient/src/forms/LoginForm/LoginForm.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
40
webclient/src/hooks/__mocks__/useKnownHosts.ts
Normal file
40
webclient/src/hooks/__mocks__/useKnownHosts.ts
Normal 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())
|
||||
);
|
||||
20
webclient/src/hooks/__mocks__/useSettings.ts
Normal file
20
webclient/src/hooks/__mocks__/useSettings.ts
Normal 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())
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
}
|
||||
169
webclient/src/hooks/useAutoLogin.spec.tsx
Normal file
169
webclient/src/hooks/useAutoLogin.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
76
webclient/src/hooks/useAutoLogin.ts
Normal file
76
webclient/src/hooks/useAutoLogin.ts
Normal 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]);
|
||||
}
|
||||
211
webclient/src/hooks/useKnownHosts.spec.ts
Normal file
211
webclient/src/hooks/useKnownHosts.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
132
webclient/src/hooks/useKnownHosts.ts
Normal file
132
webclient/src/hooks/useKnownHosts.ts
Normal 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();
|
||||
98
webclient/src/hooks/useSettings.spec.ts
Normal file
98
webclient/src/hooks/useSettings.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
52
webclient/src/hooks/useSettings.ts
Normal file
52
webclient/src/hooks/useSettings.ts
Normal 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();
|
||||
102
webclient/src/hooks/useSharedStore.spec.ts
Normal file
102
webclient/src/hooks/useSharedStore.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
127
webclient/src/hooks/useSharedStore.ts
Normal file
127
webclient/src/hooks/useSharedStore.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
open: true,
|
||||
watch: {
|
||||
ignored: ['build', 'coverage', 'integration']
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue