Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb51f043ac | |||
| 80d7728e66 | |||
| 83456c018c |
@@ -25,6 +25,48 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget [server-directory] [rooms]
|
||||||
|
|
||||||
|
- **Trigger:** creating a server appeared to work (the creator landed in the room view) but the server didn't exist on the backend — invite-link creation and search both 404'd. `createRoom$` sent `ownerPublicKey: currentUser.oderId` with no fallback; on restored sessions `oderId` can be falsy (identify still works because it falls back to `id`), so `POST /api/servers` returned `400 Missing required fields`, and the `.subscribe()` swallowed the error while `createRoomSuccess` fired regardless.
|
||||||
|
- **Rule:** resolve owner identity as `oderId || id` everywhere it's required (the server rejects an empty `ownerPublicKey`), and give `registerServer().subscribe()` an `error` handler so a failed registration is never silent.
|
||||||
|
- **Why:** verified against the live server — authed POST with a truthy `ownerPublicKey` → 201; authed POST with an empty one → 400; the swallowed 400 is exactly what produces a "ghost" room the creator can enter but no one can find.
|
||||||
|
- **Example:** `buildServerRegistrationPayload(room, currentUser, normalizedPassword)` in `toju-app/src/app/store/rooms/server-registration.rules.ts`, used by `RoomsEffects.createRoom$`.
|
||||||
|
|
||||||
|
### Identify must fall back to the legacy session token, not only the new credential store [realtime] [authentication]
|
||||||
|
|
||||||
|
- **Trigger:** the multi-signal-server auth refactor changed `resolveCredentialForSignalUrl` to read *only* `SignalServerCredentialStoreService`; sessions restored from disk (and logins where `user.homeSignalServerUrl` is unset) have an empty credential store, so `identify` was skipped on every signal server ("Skipping identify because no session token is available") and users appeared alone — no presence, no peers, sent messages visible only to themselves. E2E never caught it because every e2e flow does a *fresh* register/login that writes the credential store directly.
|
||||||
|
- **Rule:** when resolving the identify credential for a signal URL, prefer the per-signal credential but fall back to the legacy `AuthTokenStoreService` token reconstructed with the current home user's `id`/`displayName`; never gate `identify` solely on the new credential store.
|
||||||
|
- **Why:** `persistSessionToken` always writes the legacy `metoyou.authTokens` store on login, but the per-signal credential store is only populated on fresh login (with a `loginResponse`) or successful migration/provisioning — so on reload it can be empty while a valid session still exists.
|
||||||
|
- **Example:** `resolveSignalIdentity(credential, legacyTokenEntry, homeUser)` in `signal-server-credential-resolution.rules.ts`, wired through `SignalServerAuthService.resolveCredentialForSignalUrl` (which now passes `this.authTokenStore.getTokenEntry(httpUrl)` and a `homeUser` carrying `id`). Test cross-user behavior via a *session-restore* path, not just fresh login.
|
||||||
|
|
||||||
|
### Keep the per-signal-URL identify credential resolvable from the store [realtime] [authentication]
|
||||||
|
|
||||||
|
- **Trigger:** after the multi-signal-server auth refactor, `SignalingManager.getLastIdentify` was switched to `getIdentifyCredentialsForSignalUrl`, which only read an in-memory cache populated *after* `identify()` ran; a freshly (re)connected socket then emitted `join_server` before any identify and users silently never appeared in the presence roster (almost all multi-user e2e tests timed out waiting for the peer's `room-user-card`).
|
||||||
|
- **Rule:** `getIdentifyCredentialsForSignalUrl` must fall back to resolving the credential from the credential store so a new socket's `onopen` re-identifies before it re-joins; never restrict it to only the in-memory identify cache.
|
||||||
|
- **Why:** the server drops `join_server`/`view_server` on any unauthenticated connection, so an identify-less join is lost with no error and recovery only happens on a later reconnect (often beyond the 20s test timeout).
|
||||||
|
- **Example:** server log showed `join_server authed=false ... display=User` dropped, then `User identified: Alice` on a different connection but no `Alice joined server`; fixed in `signaling-transport-handler.ts` by resolving via `dependencies.resolveCredential(signalUrl)` when the cache is empty.
|
||||||
|
|
||||||
|
### Store clientInstanceId in sessionStorage not localStorage [realtime] [multi-device]
|
||||||
|
|
||||||
|
- **Trigger:** same user logged in on two tabs, browsers, or synced profiles sees alternating "Disconnected from signaling server" and no cross-device chat/voice sync.
|
||||||
|
- **Rule:** persist `metoyou.clientInstanceId` in `sessionStorage` (one id per tab/window) and clear any legacy `localStorage` copy on first read.
|
||||||
|
- **Why:** server identify evicts stale sockets with the same `(oderId, connectionScope, clientInstanceId)` tuple; a shared localStorage id makes each client kick the other in a reconnect loop.
|
||||||
|
- **Example:** `ClientInstanceService.getClientInstanceId()` writes to `sessionStorage`; two tabs get different ids and stay connected simultaneously.
|
||||||
|
|
||||||
|
### Revalidate IndexedDB scope without reinitializing on every read [persistence] [performance]
|
||||||
|
|
||||||
|
- **Trigger:** `DatabaseService.ensureReady()` called `initialize()` before every delegated read/write to fix user-scope races.
|
||||||
|
- **Rule:** cache the last validated `metoyou_currentUserId` and only re-run backend initialization when that scope changes or an in-flight initialize completes with a different scope.
|
||||||
|
- **Why:** per-operation revalidation fans out across ban lookups, room loads, and message reads, causing channel/chat UI to stay blank until repeated server clicks eventually win the race.
|
||||||
|
- **Example:** `ensureReady()` returns immediately when `isReady()` and `validatedUserScope` still match `getStoredCurrentUserId()`.
|
||||||
|
|
||||||
|
### Restore local user scope before protected writes [authentication] [persistence]
|
||||||
|
|
||||||
|
- **Trigger:** a logged-in in-memory user can create rooms or messages after `metoyou_currentUserId` was cleared by a late session-expired path.
|
||||||
|
- **Rule:** before protected local persistence or server-directory actions, restore `metoyou_currentUserId` from the current user and avoid treating a live current user as unauthenticated.
|
||||||
|
- **Why:** otherwise rooms/messages fall into the anonymous IndexedDB scope, and route checks redirect to login even though NgRx still has the authenticated user.
|
||||||
|
- **Example:** `MessagesEffects.sendMessage$`, `RoomsEffects.createRoom$`, and server-directory create/join components call `setStoredCurrentUserId(currentUser.id)` before writing or joining.
|
||||||
|
|
||||||
### Persisted local user state still requires a session token [authentication] [signaling]
|
### Persisted local user state still requires a session token [authentication] [signaling]
|
||||||
|
|
||||||
- **Trigger:** Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped.
|
- **Trigger:** Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped.
|
||||||
|
|||||||
16
agents-docs/adr/0003-multi-client-sessions.md
Normal file
16
agents-docs/adr/0003-multi-client-sessions.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# ADR-0003: Multi-Client Sessions with Connection-Scoped Routing
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Users expect to stay logged in on multiple devices simultaneously (Discord-style). The signaling server already issued multiple session tokens per user, but WebSocket broadcasts deduplicated by `oderId`, which prevented a user's second device from receiving chat, typing, or voice-state updates from their first device. Voice had no per-device identity, so two clients could both attempt to transmit audio.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Introduce a stable per-install `clientInstanceId` on the product client. Route server broadcasts by **connection id** (exclude only the sender socket) while keeping presence `user_joined` / `user_left` identity-scoped. Track `voiceActive` per connection; relay RTC to the voice-active socket. Enforce single voice owner per user via `VoiceState.clientInstanceId` and `voice_client_takeover` handoff between connections.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- **Positive:** Chat and presence sync across a user's devices; voice behaves like Discord (one transmitting client, passive viewers, explicit takeover).
|
||||||
|
- **Positive:** Stale-tab hygiene uses `(oderId, connectionScope, clientInstanceId)` eviction without kicking other devices.
|
||||||
|
- **Negative:** `findUserByOderId` semantics change — RTC now prefers voice-active connections; callers must not assume one socket per user.
|
||||||
|
- **Negative:** Clients must include `clientInstanceId` on identify and voice payloads; older builds without it still work but cannot participate in multi-device voice exclusivity reliably.
|
||||||
@@ -25,7 +25,7 @@ Session-token authentication for the signaling server and product client.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`).
|
- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`).
|
||||||
- Default TTL: 24 hours (`SESSION_TOKEN_TTL_MS` env override supported).
|
- Default TTL: 10 years (`SESSION_TOKEN_TTL_MS` env override supported on the signaling server).
|
||||||
- Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login.
|
- Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login.
|
||||||
|
|
||||||
## Protected REST routes
|
## Protected REST routes
|
||||||
@@ -46,18 +46,42 @@ Require `Authorization: Bearer`:
|
|||||||
"token": "<session-token>",
|
"token": "<session-token>",
|
||||||
"oderId": "<user-id>",
|
"oderId": "<user-id>",
|
||||||
"displayName": "Alice",
|
"displayName": "Alice",
|
||||||
"connectionScope": "ws://host:3001"
|
"connectionScope": "ws://host:3001",
|
||||||
|
"clientInstanceId": "<per-install-uuid>"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `oderId` must match the token's user id when provided.
|
- `oderId` must match the token's user id when provided.
|
||||||
|
- `clientInstanceId` is a stable per-install UUID generated by the product client (`metoyou.clientInstanceId` in `localStorage`). The signaling server uses it to distinguish multiple WebSocket connections for the same user and to route voice ownership.
|
||||||
- Server responds with `auth_error` or `auth_required` when authentication fails.
|
- Server responds with `auth_error` or `auth_required` when authentication fails.
|
||||||
|
|
||||||
|
## Multi-device sessions
|
||||||
|
|
||||||
|
- Each login/register issues a **new** session token; prior tokens remain valid until they expire or the client calls `POST /api/users/logout` with that token.
|
||||||
|
- The same user may keep multiple WebSocket connections open (different devices or browser profiles). Server broadcasts (chat, typing, voice state, status) exclude only the **sending connection**, so other connections for that identity still receive updates.
|
||||||
|
- Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device.
|
||||||
|
- Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple.
|
||||||
|
|
||||||
## Client storage
|
## Client storage
|
||||||
|
|
||||||
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.
|
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.
|
||||||
|
|
||||||
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home/active signaling server (or any stored token as a fallback). Missing or rejected tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. WebSocket `auth_required` / `auth_error` responses trigger the same path.
|
Per-server credentials (`metoyou.signalServerCredentials`) map each normalized signal-server URL to the authenticated user id, username, display name, session token, expiry, and whether the account was auto-provisioned. The home user profile in SQLite/NgRx remains the device-local identity (`homeSignalServerUrl`); foreign-server credentials are a side map used for REST and WebSocket identify on that URL.
|
||||||
|
|
||||||
|
A per-install **provision secret** enables silent account creation on newly added or encountered signal servers. It is generated on home register/login, stored in Electron `safeStorage` when available (sessionStorage fallback on web), and never persisted as the user's visible login password.
|
||||||
|
|
||||||
|
### Multi-signal-server auth flows
|
||||||
|
|
||||||
|
| Flow | Action | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| Home login/register | `authenticateUser` | Resets local state, stores home credential + provision secret |
|
||||||
|
| Foreign login/register | `authorizeSignalServer` | Upserts credential for that URL only; home session unchanged |
|
||||||
|
| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-<homeUserIdPrefix>`) |
|
||||||
|
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
||||||
|
|
||||||
|
Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.
|
||||||
|
|
||||||
|
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home signaling server (or any stored token as a fallback). Missing or rejected **home** tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. Foreign-server `auth_required` / `auth_error` responses clear only that server's credential and attempt re-provision.
|
||||||
|
|
||||||
## Security considerations
|
## Security considerations
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export const test = base.extend<MultiClientFixture>({
|
|||||||
|
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
permissions: ['microphone', 'camera'],
|
permissions: ['microphone', 'camera'],
|
||||||
baseURL: 'http://localhost:4200'
|
baseURL: 'http://localhost:4200',
|
||||||
|
viewport: { width: 1440, height: 900 }
|
||||||
});
|
});
|
||||||
|
|
||||||
await installTestServerEndpoint(context, testServer.port);
|
await installTestServerEndpoint(context, testServer.port);
|
||||||
|
|||||||
20
e2e/helpers/app-menu.ts
Normal file
20
e2e/helpers/app-menu.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export async function openTitleBarMenu(page: Page): Promise<void> {
|
||||||
|
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||||
|
|
||||||
|
await expect(menuButton).toBeVisible({ timeout: 15_000 });
|
||||||
|
await menuButton.click();
|
||||||
|
await expect(page.locator('app-title-bar .absolute.right-0.top-full').first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPluginStore(page: Page): Promise<void> {
|
||||||
|
await openTitleBarMenu(page);
|
||||||
|
await page.getByRole('button', { name: 'Plugin Store' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSettingsFromMenu(page: Page): Promise<void> {
|
||||||
|
await openTitleBarMenu(page);
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type APIRequestContext, type Page } from '@playwright/test';
|
import { type APIRequestContext, type Page } from '@playwright/test';
|
||||||
|
|
||||||
export const AUTH_TOKENS_STORAGE_KEY = 'metoyou.authTokens';
|
export const AUTH_TOKENS_STORAGE_KEY = 'metoyou.authTokens';
|
||||||
|
export const SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY = 'metoyou.signalServerCredentials';
|
||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,6 +57,36 @@ export async function loginTestUser(
|
|||||||
return await response.json() as AuthSession;
|
return await response.json() as AuthSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readSignalServerCredentialFromPage(
|
||||||
|
page: Page,
|
||||||
|
serverUrl: string
|
||||||
|
): Promise<{ userId: string; token: string; username: string } | null> {
|
||||||
|
return await page.evaluate(({ storageKey, url }) => {
|
||||||
|
try {
|
||||||
|
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, {
|
||||||
|
userId: string;
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}>;
|
||||||
|
const normalizedUrl = url.trim().replace(/\/+$/, '');
|
||||||
|
const entry = store[normalizedUrl];
|
||||||
|
|
||||||
|
if (!entry || entry.expiresAt <= Date.now()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: entry.userId,
|
||||||
|
token: entry.token,
|
||||||
|
username: entry.username
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, { storageKey: SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY, url: serverUrl });
|
||||||
|
}
|
||||||
|
|
||||||
export async function readAuthTokenFromPage(page: Page, serverUrl: string): Promise<string | null> {
|
export async function readAuthTokenFromPage(page: Page, serverUrl: string): Promise<string | null> {
|
||||||
return await page.evaluate(({ storageKey, url }) => {
|
return await page.evaluate(({ storageKey, url }) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
11
e2e/helpers/dashboard.ts
Normal file
11
e2e/helpers/dashboard.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Dashboard omnibox (desktop placeholder copy changed with i18n refresh). */
|
||||||
|
export function dashboardSearchInput(page: Page) {
|
||||||
|
return page.getByRole('textbox', { name: 'Search people, servers, and invites' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDashboardReady(page: Page, timeout = 30_000): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout });
|
||||||
|
await expect(dashboardSearchInput(page)).toBeVisible({ timeout });
|
||||||
|
}
|
||||||
219
e2e/helpers/multi-device-session.ts
Normal file
219
e2e/helpers/multi-device-session.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { type Client } from '../fixtures/multi-client';
|
||||||
|
import { LoginPage } from '../pages/login.page';
|
||||||
|
import { RegisterPage } from '../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../pages/server-search.page';
|
||||||
|
import { ChatRoomPage } from '../pages/chat-room.page';
|
||||||
|
import { ChatMessagesPage } from '../pages/chat-messages.page';
|
||||||
|
|
||||||
|
export const MULTI_DEVICE_PASSWORD = 'TestPass123!';
|
||||||
|
export const MULTI_DEVICE_VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
|
export interface MultiDeviceCredentials {
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiDeviceScenario {
|
||||||
|
clientA: Client;
|
||||||
|
clientB: Client;
|
||||||
|
credentials: MultiDeviceCredentials;
|
||||||
|
serverName: string;
|
||||||
|
messagesA: ChatMessagesPage;
|
||||||
|
messagesB: ChatMessagesPage;
|
||||||
|
roomA: ChatRoomPage;
|
||||||
|
roomB: ChatRoomPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueMultiDeviceName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMultiDeviceScenario(
|
||||||
|
createClient: () => Promise<Client>,
|
||||||
|
options: { suffix?: string; serverDescription?: string } = {}
|
||||||
|
): Promise<MultiDeviceScenario> {
|
||||||
|
const suffix = options.suffix ?? uniqueMultiDeviceName('multi-device');
|
||||||
|
const credentials: MultiDeviceCredentials = {
|
||||||
|
username: `multi_${suffix}`,
|
||||||
|
displayName: 'Multi Device User',
|
||||||
|
password: MULTI_DEVICE_PASSWORD
|
||||||
|
};
|
||||||
|
const serverName = `Multi Device Server ${suffix}`;
|
||||||
|
const clientA = await createClient();
|
||||||
|
const clientB = await createClient();
|
||||||
|
|
||||||
|
await warmClientPage(clientA.page);
|
||||||
|
await warmClientPage(clientB.page);
|
||||||
|
|
||||||
|
const registerPage = new RegisterPage(clientA.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||||
|
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const searchA = new ServerSearchPage(clientA.page);
|
||||||
|
|
||||||
|
await searchA.createServer(serverName, {
|
||||||
|
description: options.serverDescription ?? 'Multi-device session coverage'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
await waitForCurrentRoomName(clientA.page, serverName);
|
||||||
|
|
||||||
|
const roomA = new ChatRoomPage(clientA.page);
|
||||||
|
|
||||||
|
await roomA.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
|
||||||
|
|
||||||
|
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
|
||||||
|
await waitForCurrentRoomName(clientB.page, serverName);
|
||||||
|
|
||||||
|
const messagesA = new ChatMessagesPage(clientA.page);
|
||||||
|
const messagesB = new ChatMessagesPage(clientB.page);
|
||||||
|
const roomB = new ChatRoomPage(clientB.page);
|
||||||
|
|
||||||
|
await messagesA.waitForReady();
|
||||||
|
await messagesB.waitForReady();
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientA,
|
||||||
|
clientB,
|
||||||
|
credentials,
|
||||||
|
serverName,
|
||||||
|
messagesA,
|
||||||
|
messagesB,
|
||||||
|
roomA,
|
||||||
|
roomB
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginSecondDeviceIntoServer(
|
||||||
|
page: Page,
|
||||||
|
credentials: MultiDeviceCredentials,
|
||||||
|
serverName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
|
||||||
|
await loginPage.goto();
|
||||||
|
await loginPage.login(credentials.username, credentials.password);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const search = new ServerSearchPage(page);
|
||||||
|
|
||||||
|
await search.joinServerFromSearch(serverName);
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectCrossDeviceMessage(
|
||||||
|
sender: ChatMessagesPage,
|
||||||
|
receiver: ChatMessagesPage,
|
||||||
|
message: string,
|
||||||
|
timeout = 60_000
|
||||||
|
): Promise<void> {
|
||||||
|
await sender.sendMessage(message);
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
return await receiver.getMessageItemByText(message).isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
}, { timeout }).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warmClientPage(page: Page): Promise<void> {
|
||||||
|
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expectedRoomName) => {
|
||||||
|
interface RoomShape { name?: string }
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||||
|
|
||||||
|
return currentRoom?.name === expectedRoomName;
|
||||||
|
},
|
||||||
|
roomName,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readClientInstanceId(page: Page): Promise<string | null> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const sessionId = sessionStorage.getItem('metoyou.clientInstanceId')?.trim();
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return localStorage.getItem('metoyou.clientInstanceId')?.trim() ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutFromMenu(page: Page): Promise<void> {
|
||||||
|
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||||
|
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||||
|
|
||||||
|
await expect(menuButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await menuButton.click();
|
||||||
|
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await logoutButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function channelsSidePanel(page: Page) {
|
||||||
|
return page.locator('app-rooms-side-panel').first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function membersSidePanel(page: Page) {
|
||||||
|
return page.locator('app-rooms-side-panel').last();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passiveVoiceChannelJoinBadge(page: Page, channelName = MULTI_DEVICE_VOICE_CHANNEL) {
|
||||||
|
return page
|
||||||
|
.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`)
|
||||||
|
.getByText('Join', { exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectPassiveVoiceOnDevice(
|
||||||
|
page: Page,
|
||||||
|
options: { timeout?: number; displayName?: string; channelName?: string } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const timeout = options.timeout ?? 45_000;
|
||||||
|
const channelName = options.channelName ?? MULTI_DEVICE_VOICE_CHANNEL;
|
||||||
|
const displayName = options.displayName;
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const membersLabel = await membersSidePanel(page)
|
||||||
|
.getByText('In voice on another device', { exact: false })
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
const grayedVoiceUser = displayName
|
||||||
|
? await channelsSidePanel(page).locator('.opacity-50')
|
||||||
|
.filter({ hasText: displayName })
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return membersLabel || joinBadge || grayedVoiceUser;
|
||||||
|
}, { timeout }).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectActiveVoiceOnDevice(page: Page, timeout = 20_000): Promise<void> {
|
||||||
|
await expect(page.locator('app-voice-controls, app-voice-workspace').first()).toBeVisible({ timeout });
|
||||||
|
}
|
||||||
19
e2e/helpers/plugin-store.ts
Normal file
19
e2e/helpers/plugin-store.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export const E2E_PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json';
|
||||||
|
export const E2E_PLUGIN_TITLE = 'E2E All API Plugin';
|
||||||
|
|
||||||
|
export async function addPluginSource(page: Page, sourceUrl = E2E_PLUGIN_SOURCE_URL): Promise<void> {
|
||||||
|
const sourceInput = page.getByLabel('Plugin source manifest URL');
|
||||||
|
|
||||||
|
await expect(sourceInput).toBeVisible({ timeout: 15_000 });
|
||||||
|
await sourceInput.click();
|
||||||
|
await sourceInput.fill(sourceUrl);
|
||||||
|
await expect(sourceInput).toHaveValue(sourceUrl, { timeout: 5_000 });
|
||||||
|
|
||||||
|
const addSourceButton = page.getByRole('button', { name: 'Add Source' });
|
||||||
|
|
||||||
|
await expect(addSourceButton).toBeEnabled({ timeout: 10_000 });
|
||||||
|
await addSourceButton.click();
|
||||||
|
await expect(page.getByRole('heading', { name: E2E_PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { type Page } from '@playwright/test';
|
import { type BrowserContext, type Page } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||||
* Tracks all created peer connections and their remote tracks so tests
|
* Tracks all created peer connections and their remote tracks so tests
|
||||||
* can inspect WebRTC state via `page.evaluate()`.
|
* can inspect WebRTC state via `page.evaluate()`.
|
||||||
*
|
*
|
||||||
* Call immediately after page creation, before any `goto()`.
|
* Call on the browser context (preferred) or page before any `goto()`.
|
||||||
*/
|
*/
|
||||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
export async function installWebRTCTracking(target: BrowserContext | Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
const addInitScript = 'addInitScript' in target && typeof target.addInitScript === 'function'
|
||||||
|
? target.addInitScript.bind(target)
|
||||||
|
: (target as Page).addInitScript.bind(target);
|
||||||
|
|
||||||
|
await addInitScript(() => {
|
||||||
const connections: RTCPeerConnection[] = [];
|
const connections: RTCPeerConnection[] = [];
|
||||||
const dataChannels: RTCDataChannel[] = [];
|
const dataChannels: RTCDataChannel[] = [];
|
||||||
const syntheticMediaResources: {
|
const syntheticMediaResources: {
|
||||||
@@ -197,6 +201,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
|
|||||||
() => (window as any).__rtcConnections?.some(
|
() => (window as any).__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false,
|
) ?? false,
|
||||||
|
undefined,
|
||||||
{ timeout }
|
{ timeout }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -611,6 +616,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
undefined,
|
||||||
{ timeout }
|
{ timeout }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -818,6 +824,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
undefined,
|
||||||
{ timeout }
|
{ timeout }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,22 @@ export class ChatMessagesPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(content: string): Promise<void> {
|
async sendMessage(content: string): Promise<void> {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
try {
|
||||||
await this.waitForReady();
|
await this.waitForReady();
|
||||||
await this.composerInput.fill(content);
|
await this.composerInput.fill(content);
|
||||||
|
await expect(this.composerInput).toHaveValue(content, { timeout: 5_000 });
|
||||||
|
await expect(this.sendButton).toBeEnabled({ timeout: 5_000 });
|
||||||
await this.sendButton.click();
|
await this.sendButton.click();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error ? lastError : new Error('Failed to send chat message');
|
||||||
}
|
}
|
||||||
|
|
||||||
async typeDraft(content: string): Promise<void> {
|
async typeDraft(content: string): Promise<void> {
|
||||||
@@ -44,6 +57,13 @@ export class ChatMessagesPage {
|
|||||||
await this.composerInput.fill(content);
|
await this.composerInput.fill(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Types into the composer in a way that emits input/typing events (not just fill). */
|
||||||
|
async typeDraftWithTypingEvents(content: string): Promise<void> {
|
||||||
|
await this.waitForReady();
|
||||||
|
await this.composerInput.click();
|
||||||
|
await this.composerInput.pressSequentially(content, { delay: 40 });
|
||||||
|
}
|
||||||
|
|
||||||
async clearDraft(): Promise<void> {
|
async clearDraft(): Promise<void> {
|
||||||
await this.waitForReady();
|
await this.waitForReady();
|
||||||
await this.composerInput.fill('');
|
await this.composerInput.fill('');
|
||||||
|
|||||||
@@ -10,15 +10,14 @@ export class LoginPage {
|
|||||||
readonly registerLink: Locator;
|
readonly registerLink: Locator;
|
||||||
|
|
||||||
constructor(private page: Page) {
|
constructor(private page: Page) {
|
||||||
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]')
|
this.form = page.locator('form').filter({ has: page.locator('#login-username') });
|
||||||
.first();
|
|
||||||
|
|
||||||
this.usernameInput = page.locator('#login-username');
|
this.usernameInput = page.locator('#login-username');
|
||||||
this.passwordInput = page.locator('#login-password');
|
this.passwordInput = page.locator('#login-password');
|
||||||
this.serverSelect = page.locator('#login-server');
|
this.serverSelect = page.locator('#login-server');
|
||||||
this.submitButton = this.form.getByRole('button', { name: 'Login' });
|
this.submitButton = this.form.getByRole('button', { name: 'Login' });
|
||||||
this.errorText = page.locator('.text-destructive');
|
this.errorText = page.locator('.text-destructive');
|
||||||
this.registerLink = this.form.getByRole('button', { name: 'Register' });
|
this.registerLink = page.getByRole('button', { name: 'Register' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async goto() {
|
async goto() {
|
||||||
|
|||||||
27
e2e/run-playwright.mjs
Normal file
27
e2e/run-playwright.mjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const e2eDirectory = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const env = { ...process.env };
|
||||||
|
const browsersPath = env.PLAYWRIGHT_BROWSERS_PATH;
|
||||||
|
|
||||||
|
if (browsersPath?.includes('/cursor-sandbox-cache/')) {
|
||||||
|
delete env.PLAYWRIGHT_BROWSERS_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [command = 'test', ...args] = process.argv.slice(2);
|
||||||
|
const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||||
|
const child = spawn(executable, ['playwright', command, ...args], {
|
||||||
|
cwd: e2eDirectory,
|
||||||
|
env,
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code ?? 1);
|
||||||
|
});
|
||||||
153
e2e/tests/auth/login-return-url.spec.ts
Normal file
153
e2e/tests/auth/login-return-url.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import { LoginPage } from '../../pages/login.page';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
|
||||||
|
interface TestUser {
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Login returnUrl handling', () => {
|
||||||
|
test.describe.configure({ timeout: 120_000 });
|
||||||
|
|
||||||
|
test('unwraps nested login returnUrl chains after successful login', async ({ createClient }) => {
|
||||||
|
const client = await createClient();
|
||||||
|
const { page } = client;
|
||||||
|
const suffix = uniqueName('nested-return');
|
||||||
|
const user: TestUser = {
|
||||||
|
username: `user_${suffix}`,
|
||||||
|
displayName: 'Return Url User',
|
||||||
|
password: 'TestPass123!'
|
||||||
|
};
|
||||||
|
|
||||||
|
await test.step('Create an account', async () => {
|
||||||
|
const registerPage = new RegisterPage(page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(user.username, user.displayName, user.password);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Log out and open a deeply nested login returnUrl', async () => {
|
||||||
|
await logout(page);
|
||||||
|
|
||||||
|
const nestedReturnUrl = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
|
||||||
|
|
||||||
|
await page.goto(`/login?returnUrl=${encodeURIComponent(nestedReturnUrl)}`, {
|
||||||
|
waitUntil: 'domcontentloaded'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Login lands on the original destination instead of looping on /login', async () => {
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
|
||||||
|
await loginPage.login(user.username, user.password);
|
||||||
|
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||||
|
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects unauthenticated /servers visits to login and returns there after login', async ({ createClient }) => {
|
||||||
|
const client = await createClient();
|
||||||
|
const { page } = client;
|
||||||
|
const suffix = uniqueName('servers-return');
|
||||||
|
const user: TestUser = {
|
||||||
|
username: `user_${suffix}`,
|
||||||
|
displayName: 'Servers Return User',
|
||||||
|
password: 'TestPass123!'
|
||||||
|
};
|
||||||
|
|
||||||
|
await test.step('Create an account and log out', async () => {
|
||||||
|
const registerPage = new RegisterPage(page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(user.username, user.displayName, user.password);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
await logout(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Visiting /servers sends the user to a single-level login returnUrl', async () => {
|
||||||
|
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||||
|
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
|
||||||
|
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Logging in returns to /servers', async () => {
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
|
||||||
|
await loginPage.login(user.username, user.password);
|
||||||
|
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||||
|
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lets a returning user log back in after an expired session redirect', async ({ createClient }) => {
|
||||||
|
const client = await createClient();
|
||||||
|
const { page } = client;
|
||||||
|
const suffix = uniqueName('expired-session');
|
||||||
|
const user: TestUser = {
|
||||||
|
username: `user_${suffix}`,
|
||||||
|
displayName: 'Expired Session User',
|
||||||
|
password: 'TestPass123!'
|
||||||
|
};
|
||||||
|
|
||||||
|
await test.step('Create an account', async () => {
|
||||||
|
const registerPage = new RegisterPage(page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(user.username, user.displayName, user.password);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Simulate an expired session while keeping the persisted user id', async () => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const storageKey = 'metoyou.authTokens';
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, { token: string; expiresAt: number }>;
|
||||||
|
const expiredStore = Object.fromEntries(
|
||||||
|
Object.entries(parsed).map(([url, entry]) => [url, { ...entry, expiresAt: 0 }])
|
||||||
|
);
|
||||||
|
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(expiredStore));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||||
|
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
|
||||||
|
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The user can authenticate again and reach /servers', async () => {
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
|
||||||
|
await loginPage.login(user.username, user.password);
|
||||||
|
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||||
|
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function logout(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||||
|
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||||
|
|
||||||
|
await expect(menuButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await menuButton.click();
|
||||||
|
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await logoutButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
}
|
||||||
94
e2e/tests/auth/multi-device-session.spec.ts
Normal file
94
e2e/tests/auth/multi-device-session.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import {
|
||||||
|
MULTI_DEVICE_VOICE_CHANNEL,
|
||||||
|
channelsSidePanel,
|
||||||
|
createMultiDeviceScenario,
|
||||||
|
expectCrossDeviceMessage,
|
||||||
|
expectActiveVoiceOnDevice,
|
||||||
|
expectPassiveVoiceOnDevice,
|
||||||
|
logoutFromMenu,
|
||||||
|
membersSidePanel,
|
||||||
|
passiveVoiceChannelJoinBadge,
|
||||||
|
readClientInstanceId,
|
||||||
|
uniqueMultiDeviceName
|
||||||
|
} from '../../helpers/multi-device-session';
|
||||||
|
|
||||||
|
test.describe('Multi-device session', () => {
|
||||||
|
test.describe.configure({ timeout: 300_000, retries: 1 });
|
||||||
|
|
||||||
|
test('covers identity, chat sync, typing exclusion, and voice exclusivity', async ({ createClient }) => {
|
||||||
|
const scenario = await createMultiDeviceScenario(createClient);
|
||||||
|
const messageAtoB = `Cross-device A to B ${uniqueMultiDeviceName('msg')}`;
|
||||||
|
const messageBtoA = `Cross-device B to A ${uniqueMultiDeviceName('msg')}`;
|
||||||
|
const typingDraft = `Typing draft ${uniqueMultiDeviceName('draft')}`;
|
||||||
|
|
||||||
|
await test.step('assigns distinct clientInstanceId per browser context', async () => {
|
||||||
|
const instanceA = await readClientInstanceId(scenario.clientA.page);
|
||||||
|
const instanceB = await readClientInstanceId(scenario.clientB.page);
|
||||||
|
|
||||||
|
expect(instanceA).toBeTruthy();
|
||||||
|
expect(instanceB).toBeTruthy();
|
||||||
|
expect(instanceA).not.toEqual(instanceB);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('syncs chat from device A to device B', async () => {
|
||||||
|
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('syncs chat from device B to device A', async () => {
|
||||||
|
await expectCrossDeviceMessage(scenario.messagesB, scenario.messagesA, messageBtoA);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('does not show own typing indicator on the other device for the same user', async () => {
|
||||||
|
await scenario.messagesA.typeDraftWithTypingEvents(typingDraft);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
scenario.clientB.page.getByText(`${scenario.credentials.displayName} is typing`, { exact: false })
|
||||||
|
).toHaveCount(0, { timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('shows passive in-voice UI on the second device when the first joins voice', async () => {
|
||||||
|
await scenario.roomA.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||||
|
await expectActiveVoiceOnDevice(scenario.clientA.page);
|
||||||
|
|
||||||
|
await expectPassiveVoiceOnDevice(scenario.clientB.page, {
|
||||||
|
displayName: scenario.credentials.displayName
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false })
|
||||||
|
).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
channelsSidePanel(scenario.clientB.page).locator('.opacity-50')
|
||||||
|
.filter({
|
||||||
|
hasText: scenario.credentials.displayName
|
||||||
|
})
|
||||||
|
.first()
|
||||||
|
).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('shows Join takeover affordance on passive device voice channel', async () => {
|
||||||
|
await expect(passiveVoiceChannelJoinBadge(scenario.clientB.page)).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('transfers voice ownership when the passive device takes over', async () => {
|
||||||
|
await scenario.roomB.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||||
|
await expectActiveVoiceOnDevice(scenario.clientB.page);
|
||||||
|
|
||||||
|
await expectPassiveVoiceOnDevice(scenario.clientA.page, {
|
||||||
|
displayName: scenario.credentials.displayName
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('keeps the second device logged in when the first device logs out', async () => {
|
||||||
|
const message = `Still logged in ${uniqueMultiDeviceName('logout')}`;
|
||||||
|
|
||||||
|
await logoutFromMenu(scenario.clientA.page);
|
||||||
|
|
||||||
|
await scenario.messagesB.sendMessage(message);
|
||||||
|
await expect(scenario.messagesB.getMessageItemByText(message)).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(scenario.clientB.page).toHaveURL(/\/room\//, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
111
e2e/tests/auth/multi-signal-server-auth.spec.ts
Normal file
111
e2e/tests/auth/multi-signal-server-auth.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { test } from '../../fixtures/multi-client';
|
||||||
|
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
||||||
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
|
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
|
import {
|
||||||
|
readAuthTokenFromPage,
|
||||||
|
readSignalServerCredentialFromPage,
|
||||||
|
registerTestUser
|
||||||
|
} from '../../helpers/auth-api';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
|
||||||
|
const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary';
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
|
||||||
|
test.describe('Multi-signal-server authentication', () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test('auto-provisions a foreign signal server when a new endpoint is added', async ({ createClient, request }) => {
|
||||||
|
const primaryServer = await startTestServer();
|
||||||
|
const secondaryServer = await startTestServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createClient();
|
||||||
|
const suffix = `multi_auth_${Date.now()}`;
|
||||||
|
const username = `user_${suffix}`;
|
||||||
|
|
||||||
|
await installTestServerEndpoints(client.context, [
|
||||||
|
{
|
||||||
|
id: PRIMARY_ENDPOINT_ID,
|
||||||
|
name: 'E2E Primary Signal',
|
||||||
|
url: primaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await test.step('Register on the home signal server', async () => {
|
||||||
|
const register = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await register.goto();
|
||||||
|
await register.register(username, 'Multi Auth User', USER_PASSWORD);
|
||||||
|
await expectDashboardReady(client.page);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Add a second signal server in network settings', async () => {
|
||||||
|
await openSettingsFromMenu(client.page);
|
||||||
|
await client.page.getByRole('button', { name: 'Network' }).click();
|
||||||
|
|
||||||
|
await client.page.getByPlaceholder('Server name').fill('E2E Secondary Signal');
|
||||||
|
await client.page.getByPlaceholder('Server URL (e.g., http://localhost:3001)').fill(secondaryServer.url);
|
||||||
|
await client.page.getByTestId('add-signal-server-button').click();
|
||||||
|
|
||||||
|
await expect(client.page.getByText(secondaryServer.url)).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Wait for auto-provisioned credentials on the secondary server', async () => {
|
||||||
|
await expect.poll(async () =>
|
||||||
|
await readSignalServerCredentialFromPage(client.page, secondaryServer.url),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
const homeToken = await readAuthTokenFromPage(client.page, primaryServer.url);
|
||||||
|
const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url);
|
||||||
|
|
||||||
|
expect(homeToken).toBeTruthy();
|
||||||
|
expect(secondaryCredential?.username).toBe(username);
|
||||||
|
expect(secondaryCredential?.token).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Secondary credential can call authenticated APIs', async () => {
|
||||||
|
const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url);
|
||||||
|
|
||||||
|
if (!secondaryCredential) {
|
||||||
|
throw new Error('Expected secondary signal-server credential to be provisioned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request.post(`${secondaryServer.url}/api/servers`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${secondaryCredential.token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: `Secondary Provisioned Server ${suffix}`,
|
||||||
|
description: 'Created with auto-provisioned credentials',
|
||||||
|
ownerId: secondaryCredential.userId,
|
||||||
|
ownerPublicKey: 'e2e-secondary-owner-key'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.ok(), `POST /api/servers failed: ${response.status()} ${await response.text()}`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Home registration still works independently on the secondary server', async () => {
|
||||||
|
const otherUser = await registerTestUser(
|
||||||
|
request,
|
||||||
|
secondaryServer.url,
|
||||||
|
`other_${suffix}`,
|
||||||
|
USER_PASSWORD,
|
||||||
|
'Other User'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(otherUser.username).toBe(`other_${suffix}`);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await primaryServer.stop();
|
||||||
|
await secondaryServer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,14 +48,13 @@ test.describe('User session data isolation', () => {
|
|||||||
|
|
||||||
await test.step('Alice registers and creates local chat history', async () => {
|
await test.step('Alice registers and creates local chat history', async () => {
|
||||||
await registerUser(client.page, alice);
|
await registerUser(client.page, alice);
|
||||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Alice sees the same saved room and message after a full restart', async () => {
|
await test.step('Alice sees the same saved room and message after a full restart', async () => {
|
||||||
await restartPersistentClient(client, testServer.port);
|
await restartPersistentClient(client, testServer.port);
|
||||||
await openApp(client.page);
|
await openApp(client.page);
|
||||||
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 });
|
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await closePersistentClient(client);
|
await closePersistentClient(client);
|
||||||
@@ -88,11 +87,11 @@ test.describe('User session data isolation', () => {
|
|||||||
|
|
||||||
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
|
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
|
||||||
await registerUser(client.page, alice);
|
await registerUser(client.page, alice);
|
||||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
|
||||||
|
|
||||||
await restartPersistentClient(client, testServer.port);
|
await restartPersistentClient(client, testServer.port);
|
||||||
await openApp(client.page);
|
await openApp(client.page);
|
||||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
|
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
|
||||||
@@ -102,11 +101,11 @@ test.describe('User session data isolation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Bob gets only his own saved room and history after a restart', async () => {
|
await test.step('Bob gets only his own saved room and history after a restart', async () => {
|
||||||
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
|
await createServerAndSendMessage(client.page, bob, bobServerName, bobMessage);
|
||||||
|
|
||||||
await restartPersistentClient(client, testServer.port);
|
await restartPersistentClient(client, testServer.port);
|
||||||
await openApp(client.page);
|
await openApp(client.page);
|
||||||
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
|
await expectSavedRoomAndHistory(client.page, bob, bobServerName, bobMessage);
|
||||||
await expectSavedRoomHidden(client.page, aliceServerName);
|
await expectSavedRoomHidden(client.page, aliceServerName);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +116,7 @@ test.describe('User session data isolation', () => {
|
|||||||
|
|
||||||
await expectSavedRoomVisible(client.page, aliceServerName);
|
await expectSavedRoomVisible(client.page, aliceServerName);
|
||||||
await expectSavedRoomHidden(client.page, bobServerName);
|
await expectSavedRoomHidden(client.page, bobServerName);
|
||||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await closePersistentClient(client);
|
await closePersistentClient(client);
|
||||||
@@ -194,32 +193,58 @@ async function logoutUser(page: Page): Promise<void> {
|
|||||||
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
|
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
|
async function createServerAndSendMessage(page: Page, user: TestUser, serverName: string, messageText: string): Promise<void> {
|
||||||
const searchPage = new ServerSearchPage(page);
|
const searchPage = new ServerSearchPage(page);
|
||||||
const messagesPage = new ChatMessagesPage(page);
|
const messagesPage = new ChatMessagesPage(page);
|
||||||
|
|
||||||
await searchPage.createServer(serverName, {
|
await loginIfNeeded(page, user);
|
||||||
description: `User session isolation coverage for ${serverName}`
|
await ensureCurrentUserScope(page, user);
|
||||||
});
|
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
if (await waitForLoginForm(page, 5_000)) {
|
||||||
|
await loginUser(page, user);
|
||||||
|
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 10_000 });
|
||||||
|
await searchPage.serverNameInput.fill(serverName);
|
||||||
|
await searchPage.serverDescriptionInput.fill(`User session isolation coverage for ${serverName}`);
|
||||||
|
await searchPage.createSubmitButton.click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
await messagesPage.sendMessage(messageText);
|
await messagesPage.sendMessage(messageText);
|
||||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expectMessagePersistedInIndexedDb(page, messageText);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
|
async function expectSavedRoomAndHistory(page: Page, user: TestUser, roomName: string, messageText: string): Promise<void> {
|
||||||
const railRoomButton = getRailSavedRoomButton(page, roomName);
|
if (await waitForVisibleText(page, messageText, 5_000)) {
|
||||||
const messagesPage = new ChatMessagesPage(page);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
|
if (await new LoginPage(page).usernameInput.isVisible().catch(() => false)) {
|
||||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
await loginUser(page, user);
|
||||||
const searchRoomButton = getSearchSavedRoomButton(page, roomName);
|
}
|
||||||
|
|
||||||
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 });
|
await expectMessagePersistedInIndexedDb(page, messageText);
|
||||||
await searchRoomButton.click();
|
|
||||||
|
const persistedRoomId = await getPersistedRoomIdForMessage(page, messageText);
|
||||||
|
|
||||||
|
if (persistedRoomId) {
|
||||||
|
await openPersistedRoomById(page, user, persistedRoomId);
|
||||||
|
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await openSavedRoomFromRail(page, roomName)) {
|
||||||
|
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await joinServerFromSearchAfterLogin(page, user, roomName);
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
|
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
|
||||||
@@ -232,14 +257,17 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
|
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
|
||||||
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
if (await page.getByText(roomName, { exact: false }).first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||||
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
|
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
|
||||||
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
|
|
||||||
|
|
||||||
if (!page.url().includes('/servers')) {
|
if (!page.url().includes('/servers')) {
|
||||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||||
}
|
}
|
||||||
@@ -247,14 +275,227 @@ async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void
|
|||||||
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
|
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRailSavedRoomButton(page: Page, roomName: string) {
|
|
||||||
return page.locator(`button[title="${roomName}"]`).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSearchSavedRoomButton(page: Page, roomName: string) {
|
function getSearchSavedRoomButton(page: Page, roomName: string) {
|
||||||
return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true });
|
return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openSavedRoomFromRail(page: Page, roomName: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await expect(page.locator('app-servers-rail')).toBeVisible({ timeout: 10_000 });
|
||||||
|
const clicked = await page.locator('app-servers-rail button').evaluateAll((buttons, expectedName) => {
|
||||||
|
const expectedPrefix = expectedName.slice(0, 24);
|
||||||
|
const button = buttons.find((candidate) => {
|
||||||
|
const title = (candidate as HTMLButtonElement).title;
|
||||||
|
|
||||||
|
return title === expectedName || title.startsWith(expectedPrefix);
|
||||||
|
}) as HTMLButtonElement | undefined;
|
||||||
|
|
||||||
|
button?.click();
|
||||||
|
return !!button;
|
||||||
|
}, roomName);
|
||||||
|
|
||||||
|
if (!clicked) {
|
||||||
|
return await openSavedRoomFromDashboard(page, roomName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return await openSavedRoomFromDashboard(page, roomName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSavedRoomFromDashboard(page: Page, roomName: string): Promise<boolean> {
|
||||||
|
const roomNamePattern = new RegExp(escapeRegExp(roomName.slice(0, 24)));
|
||||||
|
const roomButton = page.getByRole('button', { name: roomNamePattern }).first();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(roomButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await roomButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return await joinVisibleServerFromDashboard(page, roomNamePattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinVisibleServerFromDashboard(page: Page, roomNamePattern: RegExp): Promise<boolean> {
|
||||||
|
const serverRow = page.locator('div', { hasText: roomNamePattern }).filter({
|
||||||
|
has: page.getByRole('button', { name: 'Join' })
|
||||||
|
})
|
||||||
|
.last();
|
||||||
|
const joinButton = serverRow.getByRole('button', { name: 'Join' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(joinButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await joinButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinServerFromSearchAfterLogin(page: Page, user: TestUser, roomName: string): Promise<void> {
|
||||||
|
const searchPage = new ServerSearchPage(page);
|
||||||
|
|
||||||
|
await loginIfNeeded(page, user);
|
||||||
|
await searchPage.goto();
|
||||||
|
|
||||||
|
if (!await waitForServerSearch(page, 5_000)) {
|
||||||
|
await loginUser(page, user);
|
||||||
|
await searchPage.goto();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
|
||||||
|
await searchPage.searchInput.fill(roomName);
|
||||||
|
|
||||||
|
const serverCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||||
|
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||||
|
await serverCard.dblclick();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginIfNeeded(page: Page, user: TestUser): Promise<void> {
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
|
||||||
|
if (page.url().includes('/login')) {
|
||||||
|
await expect(loginPage.usernameInput).toBeVisible({ timeout: 15_000 });
|
||||||
|
await loginUser(page, user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await loginPage.usernameInput.isVisible().catch(() => false)) {
|
||||||
|
await loginUser(page, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCurrentUserScope(page: Page, user: TestUser): Promise<void> {
|
||||||
|
if (await hasCurrentUserScope(page)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginUser(page, user);
|
||||||
|
await expect.poll(() => hasCurrentUserScope(page), { timeout: 10_000 }).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasCurrentUserScope(page: Page): Promise<boolean> {
|
||||||
|
return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise<void> {
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
if (await waitForLoginForm(page, 5_000)) {
|
||||||
|
await loginUser(page, user);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
if (!await waitForLoginForm(page, 2_000)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginUser(page, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLoginForm(page: Page, timeout: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForServerSearch(page: Page, timeout: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForVisibleText(page: Page, text: string, timeout: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise<void> {
|
||||||
|
await expect.poll(
|
||||||
|
() => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId),
|
||||||
|
{ timeout: 10_000 }
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise<string | null> {
|
||||||
|
return page.evaluate(async (expectedContent) => {
|
||||||
|
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim();
|
||||||
|
const preferredDatabaseName = `metoyou::${encodeURIComponent(currentUserId || 'anonymous')}`;
|
||||||
|
const discoveredDatabaseNames = typeof indexedDB.databases === 'function'
|
||||||
|
? (await indexedDB.databases())
|
||||||
|
.map((database) => database.name)
|
||||||
|
.filter((name): name is string => !!name && (name === 'metoyou' || name.startsWith('metoyou::')))
|
||||||
|
: null;
|
||||||
|
const databaseNames = discoveredDatabaseNames ?? [preferredDatabaseName];
|
||||||
|
const remainingDatabaseNames = databaseNames.filter((name) => name !== preferredDatabaseName);
|
||||||
|
const orderedDatabaseNames = databaseNames.includes(preferredDatabaseName)
|
||||||
|
? [preferredDatabaseName].concat(remainingDatabaseNames)
|
||||||
|
: remainingDatabaseNames;
|
||||||
|
|
||||||
|
for (const databaseName of orderedDatabaseNames) {
|
||||||
|
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(databaseName);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!database.objectStoreNames.contains('messages')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = database.transaction('messages', 'readonly');
|
||||||
|
const request = transaction.objectStore('messages').getAll();
|
||||||
|
const roomId = await new Promise<string | null>((resolve, reject) => {
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const match = ((request.result as { content?: string; roomId?: string }[]) ?? [])
|
||||||
|
.find((message) => message.content === expectedContent);
|
||||||
|
|
||||||
|
resolve(match?.roomId ?? null);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roomId) {
|
||||||
|
return roomId;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, messageText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
expect,
|
expect,
|
||||||
|
type BrowserContext,
|
||||||
type Locator,
|
type Locator,
|
||||||
type Page
|
type Page
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
@@ -35,6 +36,7 @@ test.describe('Chat notifications', () => {
|
|||||||
await clearDesktopNotifications(scenario.alice.page);
|
await clearDesktopNotifications(scenario.alice.page);
|
||||||
await scenario.bobRoom.joinTextChannel(scenario.channelName);
|
await scenario.bobRoom.joinTextChannel(scenario.channelName);
|
||||||
await scenario.bobMessages.sendMessage(message);
|
await scenario.bobMessages.sendMessage(message);
|
||||||
|
await expectUnreadCounts(scenario.alice.page, scenario.serverName, scenario.channelName);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Alice receives a desktop notification with the channel preview', async () => {
|
await test.step('Alice receives a desktop notification with the channel preview', async () => {
|
||||||
@@ -67,8 +69,7 @@ test.describe('Chat notifications', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Alice still sees unread badges for the room and channel', async () => {
|
await test.step('Alice still sees unread badges for the room and channel', async () => {
|
||||||
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
|
await expectUnreadCounts(scenario.alice.page, scenario.serverName, scenario.channelName);
|
||||||
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Alice does not get a muted desktop popup', async () => {
|
await test.step('Alice does not get a muted desktop popup', async () => {
|
||||||
@@ -96,7 +97,7 @@ async function createNotificationScenario(createClient: () => Promise<Client>):
|
|||||||
const alice = await createClient();
|
const alice = await createClient();
|
||||||
const bob = await createClient();
|
const bob = await createClient();
|
||||||
|
|
||||||
await installDesktopNotificationSpy(alice.page);
|
await installDesktopNotificationSpy(alice.context);
|
||||||
|
|
||||||
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
|
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
|
||||||
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
|
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
|
||||||
@@ -143,8 +144,8 @@ async function registerUser(page: Page, username: string, displayName: string, p
|
|||||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installDesktopNotificationSpy(page: Page): Promise<void> {
|
async function installDesktopNotificationSpy(context: BrowserContext): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await context.addInitScript(() => {
|
||||||
const notifications: DesktopNotificationRecord[] = [];
|
const notifications: DesktopNotificationRecord[] = [];
|
||||||
|
|
||||||
class MockNotification {
|
class MockNotification {
|
||||||
@@ -250,6 +251,11 @@ function getUnreadBadge(container: Locator): Locator {
|
|||||||
return container.locator('span.rounded-full').first();
|
return container.locator('span.rounded-full').first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectUnreadCounts(page: Page, serverName: string, channelName: string): Promise<void> {
|
||||||
|
await expect(getUnreadBadge(getSavedRoomButton(page, serverName))).toHaveText('1', { timeout: 45_000 });
|
||||||
|
await expect(getUnreadBadge(getTextChannelButton(page, channelName))).toHaveText('1', { timeout: 45_000 });
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
function uniqueName(prefix: string): string {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
.slice(2, 8)}`;
|
.slice(2, 8)}`;
|
||||||
|
|||||||
@@ -367,11 +367,10 @@ async function launchPersistentSession(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await installTestServerEndpoint(context, testServerPort);
|
await installTestServerEndpoint(context, testServerPort);
|
||||||
|
await installWebRTCTracking(context);
|
||||||
|
|
||||||
const page = context.pages()[0] ?? await context.newPage();
|
const page = context.pages()[0] ?? await context.newPage();
|
||||||
|
|
||||||
await installWebRTCTracking(page);
|
|
||||||
|
|
||||||
return { context, page };
|
return { context, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,11 +196,10 @@ async function launchPersistentSession(userDataDir: string, testServerPort: numb
|
|||||||
});
|
});
|
||||||
|
|
||||||
await installTestServerEndpoint(context, testServerPort);
|
await installTestServerEndpoint(context, testServerPort);
|
||||||
|
await installWebRTCTracking(context);
|
||||||
|
|
||||||
const page = context.pages()[0] ?? (await context.newPage());
|
const page = context.pages()[0] ?? (await context.newPage());
|
||||||
|
|
||||||
await installWebRTCTracking(page);
|
|
||||||
|
|
||||||
return { context, page };
|
return { context, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ import {
|
|||||||
test,
|
test,
|
||||||
type Client
|
type Client
|
||||||
} from '../../fixtures/multi-client';
|
} from '../../fixtures/multi-client';
|
||||||
|
import { openPluginStore } from '../../helpers/app-menu';
|
||||||
|
import {
|
||||||
|
addPluginSource,
|
||||||
|
E2E_PLUGIN_SOURCE_URL,
|
||||||
|
E2E_PLUGIN_TITLE
|
||||||
|
} from '../../helpers/plugin-store';
|
||||||
|
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
const PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json';
|
const PLUGIN_SOURCE_URL = E2E_PLUGIN_SOURCE_URL;
|
||||||
const PLUGIN_TITLE = 'E2E All API Plugin';
|
const PLUGIN_TITLE = E2E_PLUGIN_TITLE;
|
||||||
const EDITED_MESSAGE = 'Plugin API edited message';
|
const EDITED_MESSAGE = 'Plugin API edited message';
|
||||||
const ORIGINAL_MESSAGE = 'Plugin API original message';
|
const ORIGINAL_MESSAGE = 'Plugin API original message';
|
||||||
const DELETED_MESSAGE = 'Plugin API deleted message';
|
const DELETED_MESSAGE = 'Plugin API deleted message';
|
||||||
@@ -87,6 +94,9 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
|
|||||||
const alice = await createClient();
|
const alice = await createClient();
|
||||||
const bob = await createClient();
|
const bob = await createClient();
|
||||||
|
|
||||||
|
await installWebRTCTracking(alice.page);
|
||||||
|
await installWebRTCTracking(bob.page);
|
||||||
|
|
||||||
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
||||||
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
||||||
|
|
||||||
@@ -98,13 +108,10 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
|
|||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
|
|
||||||
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||||
await installGrantAndActivatePlugin(alice.page, true);
|
|
||||||
await closeSettingsModal(alice.page);
|
|
||||||
await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 });
|
|
||||||
|
|
||||||
const bobSearch = new ServerSearchPage(bob.page);
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
await bobSearch.joinServerFromSearch(serverName, { acceptPluginDownloads: true });
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
||||||
|
|
||||||
const bobRoom = new ChatRoomPage(bob.page);
|
const bobRoom = new ChatRoomPage(bob.page);
|
||||||
@@ -113,6 +120,9 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
|
|||||||
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
||||||
|
await installGrantAndActivatePlugin(alice.page, true);
|
||||||
|
await closeSettingsModal(alice.page);
|
||||||
|
await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
const aliceMessages = new ChatMessagesPage(alice.page);
|
const aliceMessages = new ChatMessagesPage(alice.page);
|
||||||
const bobMessages = new ChatMessagesPage(bob.page);
|
const bobMessages = new ChatMessagesPage(bob.page);
|
||||||
@@ -141,14 +151,11 @@ async function registerUser(page: Page, username: string, displayName: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> {
|
async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> {
|
||||||
await page.getByRole('button', { name: 'Plugin Store' }).click();
|
await openPluginStore(page);
|
||||||
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
|
|
||||||
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 });
|
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
if (installFromStore) {
|
if (installFromStore) {
|
||||||
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
|
await addPluginSource(page, PLUGIN_SOURCE_URL);
|
||||||
await page.getByRole('button', { name: 'Add Source' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
|
||||||
await page.locator('article', { hasText: PLUGIN_TITLE }).getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ })
|
await page.locator('article', { hasText: PLUGIN_TITLE }).getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { expect, test } from '../../fixtures/multi-client';
|
import { expect, test } from '../../fixtures/multi-client';
|
||||||
|
import { openPluginStore } from '../../helpers/app-menu';
|
||||||
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
|
import { addPluginSource } from '../../helpers/plugin-store';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
@@ -15,7 +18,7 @@ test.describe('Plugin manager UI', () => {
|
|||||||
await test.step('Register user and create server context', async () => {
|
await test.step('Register user and create server context', async () => {
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!');
|
await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!');
|
||||||
await expect(page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
await expectDashboardReady(page);
|
||||||
await search.createServer(`Plugin API Server ${suffix}`, {
|
await search.createServer(`Plugin API Server ${suffix}`, {
|
||||||
description: 'Plugin manager UI E2E coverage'
|
description: 'Plugin manager UI E2E coverage'
|
||||||
});
|
});
|
||||||
@@ -23,16 +26,13 @@ test.describe('Plugin manager UI', () => {
|
|||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Open visible Plugin Store button', async () => {
|
await test.step('Open Plugin Store from the title-bar menu', async () => {
|
||||||
await page.getByRole('button', { name: 'Plugin Store' }).click();
|
await openPluginStore(page);
|
||||||
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 10_000 });
|
|
||||||
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Install fixture plugin from source manifest', async () => {
|
await test.step('Install fixture plugin from source manifest', async () => {
|
||||||
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
|
await addPluginSource(page);
|
||||||
await page.getByRole('button', { name: 'Add Source' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
|
|
||||||
const pluginCard = page.locator('article', { hasText: 'E2E All API Plugin' });
|
const pluginCard = page.locator('article', { hasText: 'E2E All API Plugin' });
|
||||||
|
|
||||||
await pluginCard.getByRole('button', { name: 'Readme' }).click();
|
await pluginCard.getByRole('button', { name: 'Readme' }).click();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from '../../fixtures/multi-client';
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
@@ -88,7 +89,7 @@ test.describe('Connectivity warning', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||||
await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
await expectDashboardReady(alice.page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Register Bob', async () => {
|
await test.step('Register Bob', async () => {
|
||||||
@@ -96,7 +97,7 @@ test.describe('Connectivity warning', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||||
await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
await expectDashboardReady(bob.page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Register Charlie', async () => {
|
await test.step('Register Charlie', async () => {
|
||||||
@@ -104,7 +105,7 @@ test.describe('Connectivity warning', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
||||||
await expect(charlie.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
await expectDashboardReady(charlie.page);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Create server and have everyone join ──
|
// ── Create server and have everyone join ──
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { test, expect } from '../../fixtures/multi-client';
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
||||||
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
|
||||||
test.describe('ICE server settings', () => {
|
test.describe('ICE server settings', () => {
|
||||||
@@ -9,8 +11,8 @@ test.describe('ICE server settings', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
||||||
await expect(page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
await expectDashboardReady(page);
|
||||||
await page.getByTitle('Settings').click();
|
await openSettingsFromMenu(page);
|
||||||
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
||||||
await page.getByRole('button', { name: 'Network' }).click();
|
await page.getByRole('button', { name: 'Network' }).click();
|
||||||
await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 });
|
||||||
@@ -101,7 +103,7 @@ test.describe('ICE server settings', () => {
|
|||||||
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 5_000 });
|
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
await page.getByTitle('Settings').click();
|
await openSettingsFromMenu(page);
|
||||||
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
||||||
await page.getByRole('button', { name: 'Network' }).click();
|
await page.getByRole('button', { name: 'Network' }).click();
|
||||||
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from '../../fixtures/multi-client';
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
@@ -89,7 +90,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||||
await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
await expectDashboardReady(alice.page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Register Bob', async () => {
|
await test.step('Register Bob', async () => {
|
||||||
@@ -97,7 +98,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||||
await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 });
|
await expectDashboardReady(bob.page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Alice creates a server', async () => {
|
await test.step('Alice creates a server', async () => {
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let secondaryRoomId = '';
|
||||||
|
|
||||||
// ── Create rooms ────────────────────────────────────────────
|
// ── Create rooms ────────────────────────────────────────────
|
||||||
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
||||||
// Use a "both" user (client 0) to create both rooms
|
// Use a "both" user (client 0) to create both rooms
|
||||||
@@ -198,7 +200,6 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
// Group D (secondary-only) needs invite to primary room.
|
// Group D (secondary-only) needs invite to primary room.
|
||||||
let primaryRoomInviteUrl: string;
|
let primaryRoomInviteUrl: string;
|
||||||
let secondaryRoomInviteUrl: string;
|
let secondaryRoomInviteUrl: string;
|
||||||
let secondaryRoomId = '';
|
|
||||||
|
|
||||||
await test.step('Create invite links for cross-signal rooms', async () => {
|
await test.step('Create invite links for cross-signal rooms', async () => {
|
||||||
// Navigate to voice room to get its ID
|
// Navigate to voice room to get its ID
|
||||||
|
|||||||
14
electron/api/auth-store.spec.ts
Normal file
14
electron/api/auth-store.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { getLocalApiTokenTtlMs } from './auth-store';
|
||||||
|
|
||||||
|
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
describe('auth-store', () => {
|
||||||
|
it('defaults local API tokens to a very long lifetime', () => {
|
||||||
|
expect(getLocalApiTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,9 +10,13 @@ export interface IssuedToken {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||||
const tokens = new Map<string, IssuedToken>();
|
const tokens = new Map<string, IssuedToken>();
|
||||||
|
|
||||||
|
export function getLocalApiTokenTtlMs(): number {
|
||||||
|
return DEFAULT_TOKEN_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
export function issueToken(params: {
|
export function issueToken(params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -24,7 +28,7 @@ export function issueToken(params: {
|
|||||||
const issued: IssuedToken = {
|
const issued: IssuedToken = {
|
||||||
token,
|
token,
|
||||||
issuedAt,
|
issuedAt,
|
||||||
expiresAt: issuedAt + TOKEN_TTL_MS,
|
expiresAt: issuedAt + getLocalApiTokenTtlMs(),
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
username: params.username,
|
username: params.username,
|
||||||
displayName: params.displayName,
|
displayName: params.displayName,
|
||||||
|
|||||||
60
electron/api/provision-secret-store.ts
Normal file
60
electron/api/provision-secret-store.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { safeStorage } from 'electron';
|
||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
writeFile
|
||||||
|
} from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { app } from 'electron';
|
||||||
|
|
||||||
|
const STORAGE_DIR_NAME = 'provision-secrets';
|
||||||
|
|
||||||
|
function getStorageDir(): string {
|
||||||
|
return path.join(app.getPath('userData'), STORAGE_DIR_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSecretFilePath(homeUserId: string): string {
|
||||||
|
return path.join(getStorageDir(), `${homeUserId}.bin`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureStorageDir(): Promise<void> {
|
||||||
|
await mkdir(getStorageDir(), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeProvisionSecret(homeUserId: string, secret: string): Promise<boolean> {
|
||||||
|
if (!homeUserId.trim() || !secret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureStorageDir();
|
||||||
|
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
|
await writeFile(getSecretFilePath(homeUserId), secret, 'utf8');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = safeStorage.encryptString(secret);
|
||||||
|
|
||||||
|
await writeFile(getSecretFilePath(homeUserId), encrypted);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProvisionSecret(homeUserId: string): Promise<string | null> {
|
||||||
|
if (!homeUserId.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = getSecretFilePath(homeUserId);
|
||||||
|
const payload = await readFile(filePath);
|
||||||
|
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) {
|
||||||
|
return payload.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeStorage.decryptString(payload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@ import {
|
|||||||
setupWindowControlHandlers
|
setupWindowControlHandlers
|
||||||
} from '../ipc';
|
} from '../ipc';
|
||||||
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||||
|
import {
|
||||||
|
attachRendererDiagnosticsHooks,
|
||||||
|
ensurePerfDiagIpcRegistered,
|
||||||
|
shutdownPerfDiagnostics,
|
||||||
|
startPerfDiagnostics
|
||||||
|
} from '../diagnostics';
|
||||||
|
|
||||||
function startLocalApiAfterWindowReady(): void {
|
function startLocalApiAfterWindowReady(): void {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
@@ -32,6 +38,8 @@ function startLocalApiAfterWindowReady(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerAppLifecycle(): void {
|
export function registerAppLifecycle(): void {
|
||||||
|
ensurePerfDiagIpcRegistered();
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
const dockIconPath = getDockIconPath();
|
const dockIconPath = getDockIconPath();
|
||||||
|
|
||||||
@@ -45,7 +53,15 @@ export function registerAppLifecycle(): void {
|
|||||||
await migrateLegacyDesktopBranding();
|
await migrateLegacyDesktopBranding();
|
||||||
await synchronizeAutoStartSetting();
|
await synchronizeAutoStartSetting();
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
|
startPerfDiagnostics();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
|
if (mainWindow) {
|
||||||
|
attachRendererDiagnosticsHooks(mainWindow);
|
||||||
|
}
|
||||||
|
|
||||||
startLocalApiAfterWindowReady();
|
startLocalApiAfterWindowReady();
|
||||||
startIdleMonitor();
|
startIdleMonitor();
|
||||||
|
|
||||||
@@ -67,6 +83,7 @@ export function registerAppLifecycle(): void {
|
|||||||
|
|
||||||
app.on('before-quit', async (event) => {
|
app.on('before-quit', async (event) => {
|
||||||
prepareWindowForAppQuit();
|
prepareWindowForAppQuit();
|
||||||
|
await shutdownPerfDiagnostics();
|
||||||
|
|
||||||
if (getDataSource()?.isInitialized) {
|
if (getDataSource()?.isInitialized) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
27
electron/diagnostics/diagnostics.flags.spec.ts
Normal file
27
electron/diagnostics/diagnostics.flags.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||||
|
|
||||||
|
describe('isPerfDiagEnabled', () => {
|
||||||
|
it('returns false when the flag is unset', () => {
|
||||||
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
|
expect(isPerfDiagEnabled({}, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||||
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, false)).toBe(true);
|
||||||
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'true' }, false)).toBe(true);
|
||||||
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false in packaged builds unless force is set', () => {
|
||||||
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
||||||
|
expect(isPerfDiagEnabled({
|
||||||
|
METOYOU_PERF_DIAG: '1',
|
||||||
|
METOYOU_PERF_DIAG_FORCE: '1'
|
||||||
|
}, true)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
electron/diagnostics/diagnostics.flags.ts
Normal file
29
electron/diagnostics/diagnostics.flags.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const PERF_DIAG_ENV = 'METOYOU_PERF_DIAG';
|
||||||
|
export const PERF_DIAG_FORCE_ENV = 'METOYOU_PERF_DIAG_FORCE';
|
||||||
|
|
||||||
|
const TRUTHY = new Set([
|
||||||
|
'1',
|
||||||
|
'true',
|
||||||
|
'yes',
|
||||||
|
'on'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTruthyFlag(value: string | undefined): boolean {
|
||||||
|
return TRUTHY.has(String(value ?? '').trim()
|
||||||
|
.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPerfDiagEnabled(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
isPackaged: boolean
|
||||||
|
): boolean {
|
||||||
|
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
214
electron/diagnostics/diagnostics.lifecycle.ts
Normal file
214
electron/diagnostics/diagnostics.lifecycle.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
ipcMain
|
||||||
|
} from 'electron';
|
||||||
|
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||||
|
import { sumWorkingSetKb } from './process-metrics.rules';
|
||||||
|
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
import { PerfDiagWriter } from './diagnostics.writer';
|
||||||
|
|
||||||
|
const PROCESS_POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
let activeWriter: PerfDiagWriter | null = null;
|
||||||
|
let processPollTimer: NodeJS.Timeout | null = null;
|
||||||
|
let diagnosticsEnabled = false;
|
||||||
|
let ipcRegistered = false;
|
||||||
|
|
||||||
|
export function isPerfDiagActive(): boolean {
|
||||||
|
return diagnosticsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensurePerfDiagIpcRegistered(): void {
|
||||||
|
if (ipcRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcRegistered = true;
|
||||||
|
|
||||||
|
ipcMain.handle('perf-diag-is-enabled', () => diagnosticsEnabled);
|
||||||
|
|
||||||
|
ipcMain.handle('perf-diag-report', (_event, entry: PerfDiagEntry) => {
|
||||||
|
const writer = activeWriter;
|
||||||
|
|
||||||
|
if (!diagnosticsEnabled || !writer) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
writer.append(normalizeRendererEntry(entry));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||||
|
return activeWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||||
|
ensurePerfDiagIpcRegistered();
|
||||||
|
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
|
||||||
|
|
||||||
|
if (!diagnosticsEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = `${Date.now().toString(36)}-${process.pid}`;
|
||||||
|
const writer = new PerfDiagWriter({
|
||||||
|
userDataPath: app.getPath('userData'),
|
||||||
|
sessionId
|
||||||
|
});
|
||||||
|
|
||||||
|
activeWriter = writer;
|
||||||
|
registerProcessCrashHandlers(writer);
|
||||||
|
startProcessMetricsPolling(writer);
|
||||||
|
|
||||||
|
writer.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'session',
|
||||||
|
payload: {
|
||||||
|
event: 'started',
|
||||||
|
sessionId,
|
||||||
|
filePath: writer.snapshotFilePath
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return writer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachRendererDiagnosticsHooks(window: BrowserWindow): void {
|
||||||
|
const writer = activeWriter;
|
||||||
|
|
||||||
|
if (!writer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.webContents.on('render-process-gone', (_event, details) => {
|
||||||
|
writer.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'crash',
|
||||||
|
payload: {
|
||||||
|
reason: details.reason,
|
||||||
|
exitCode: details.exitCode
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void writer.flushSnapshot('render-process-gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.webContents.on('unresponsive', () => {
|
||||||
|
writer.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'unresponsive',
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.webContents.on('responsive', () => {
|
||||||
|
writer.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'session',
|
||||||
|
payload: { event: 'renderer-responsive' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdownPerfDiagnostics(): Promise<void> {
|
||||||
|
if (!activeWriter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await activeWriter.flushSnapshot('shutdown');
|
||||||
|
|
||||||
|
if (processPollTimer) {
|
||||||
|
clearInterval(processPollTimer);
|
||||||
|
processPollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeWriter = null;
|
||||||
|
diagnosticsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
||||||
|
app.on('child-process-gone', (_event, details) => {
|
||||||
|
writer.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'crash',
|
||||||
|
payload: {
|
||||||
|
type: details.type,
|
||||||
|
reason: details.reason,
|
||||||
|
exitCode: details.exitCode,
|
||||||
|
serviceName: details.serviceName ?? null,
|
||||||
|
name: details.name ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
writer.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'crash',
|
||||||
|
payload: {
|
||||||
|
scope: 'main-uncaughtException',
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void writer.flushSnapshot('uncaughtException');
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
writer.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'crash',
|
||||||
|
payload: {
|
||||||
|
scope: 'main-unhandledRejection',
|
||||||
|
reason: String(reason)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
||||||
|
const sample = (): void => {
|
||||||
|
try {
|
||||||
|
const metrics = collectAppMetricsSnapshot();
|
||||||
|
const totalKb = sumWorkingSetKb(metrics.processes);
|
||||||
|
|
||||||
|
writer.append({
|
||||||
|
collectedAt: metrics.collectedAt,
|
||||||
|
source: 'main',
|
||||||
|
type: 'process',
|
||||||
|
payload: {
|
||||||
|
totalWorkingSetKb: totalKb,
|
||||||
|
processes: metrics.processes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Collector failures must never affect the app.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sample();
|
||||||
|
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||||
|
return {
|
||||||
|
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||||
|
source: 'renderer',
|
||||||
|
type: entry.type,
|
||||||
|
payload: entry.payload ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
17
electron/diagnostics/diagnostics.models.ts
Normal file
17
electron/diagnostics/diagnostics.models.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export type PerfDiagSource = 'main' | 'renderer';
|
||||||
|
|
||||||
|
export type PerfDiagEntryType =
|
||||||
|
| 'session'
|
||||||
|
| 'process'
|
||||||
|
| 'store'
|
||||||
|
| 'components'
|
||||||
|
| 'heap'
|
||||||
|
| 'crash'
|
||||||
|
| 'unresponsive';
|
||||||
|
|
||||||
|
export interface PerfDiagEntry {
|
||||||
|
collectedAt: number;
|
||||||
|
source: PerfDiagSource;
|
||||||
|
type: PerfDiagEntryType;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
53
electron/diagnostics/diagnostics.rules.spec.ts
Normal file
53
electron/diagnostics/diagnostics.rules.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
formatPerfDiagLine,
|
||||||
|
pushRingBuffer,
|
||||||
|
resolveDiagnosticsFilePath
|
||||||
|
} from './diagnostics.rules';
|
||||||
|
|
||||||
|
describe('pushRingBuffer', () => {
|
||||||
|
it('appends items until capacity is reached', () => {
|
||||||
|
expect(pushRingBuffer([1, 2], 3, 4)).toEqual([
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the oldest items when capacity is exceeded', () => {
|
||||||
|
expect(pushRingBuffer([
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
], 4, 3)).toEqual([
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPerfDiagLine', () => {
|
||||||
|
it('serializes one JSON object per line', () => {
|
||||||
|
const line = formatPerfDiagLine({
|
||||||
|
collectedAt: 1_700_000_000_000,
|
||||||
|
source: 'main',
|
||||||
|
type: 'process',
|
||||||
|
payload: { browserKb: 128 }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(line).toBe('{"collectedAt":1700000000000,"source":"main","type":"process","payload":{"browserKb":128}}');
|
||||||
|
expect(line.endsWith('\n')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveDiagnosticsFilePath', () => {
|
||||||
|
it('places session files under diagnostics/', () => {
|
||||||
|
expect(resolveDiagnosticsFilePath('/tmp/user-data', 'session-1'))
|
||||||
|
.toBe('/tmp/user-data/diagnostics/perf-session-1.jsonl');
|
||||||
|
});
|
||||||
|
});
|
||||||
24
electron/diagnostics/diagnostics.rules.ts
Normal file
24
electron/diagnostics/diagnostics.rules.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
|
||||||
|
export function pushRingBuffer<T>(items: readonly T[], item: T, capacity: number): T[] {
|
||||||
|
const next = [...items, item];
|
||||||
|
|
||||||
|
if (next.length <= capacity) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.slice(next.length - capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPerfDiagLine(entry: PerfDiagEntry): string {
|
||||||
|
return JSON.stringify(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDiagnosticsFilePath(userDataPath: string, sessionId: string): string {
|
||||||
|
return path.join(userDataPath, 'diagnostics', `perf-${sessionId}.jsonl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDiagnosticsDirectory(userDataPath: string): string {
|
||||||
|
return path.join(userDataPath, 'diagnostics');
|
||||||
|
}
|
||||||
108
electron/diagnostics/diagnostics.writer.ts
Normal file
108
electron/diagnostics/diagnostics.writer.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
import {
|
||||||
|
formatPerfDiagLine,
|
||||||
|
pushRingBuffer,
|
||||||
|
resolveDiagnosticsFilePath
|
||||||
|
} from './diagnostics.rules';
|
||||||
|
|
||||||
|
const DEFAULT_RING_CAPACITY = 120;
|
||||||
|
const FLUSH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
export interface PerfDiagWriterOptions {
|
||||||
|
userDataPath: string;
|
||||||
|
sessionId: string;
|
||||||
|
ringCapacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerfDiagWriter {
|
||||||
|
private readonly filePath: string;
|
||||||
|
private readonly ringCapacity: number;
|
||||||
|
private readonly pendingLines: string[] = [];
|
||||||
|
private ring: PerfDiagEntry[] = [];
|
||||||
|
private flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
private flushInFlight: Promise<void> | null = null;
|
||||||
|
private disabled = false;
|
||||||
|
|
||||||
|
constructor(options: PerfDiagWriterOptions) {
|
||||||
|
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||||
|
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
get snapshotFilePath(): string {
|
||||||
|
return this.filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
get bufferedEntries(): readonly PerfDiagEntry[] {
|
||||||
|
return this.ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
append(entry: PerfDiagEntry): void {
|
||||||
|
if (this.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ring = pushRingBuffer(this.ring, entry, this.ringCapacity);
|
||||||
|
this.pendingLines.push(`${formatPerfDiagLine(entry)}\n`);
|
||||||
|
this.scheduleFlush();
|
||||||
|
} catch {
|
||||||
|
this.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
if (this.disabled || this.pendingLines.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.flushInFlight) {
|
||||||
|
await this.flushInFlight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = this.pendingLines.splice(0, this.pendingLines.length);
|
||||||
|
|
||||||
|
this.flushInFlight = this.writeLines(lines)
|
||||||
|
.catch(() => {
|
||||||
|
this.disabled = true;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.flushInFlight = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.flushInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async flushSnapshot(label: string): Promise<void> {
|
||||||
|
this.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'session',
|
||||||
|
payload: {
|
||||||
|
event: label,
|
||||||
|
filePath: this.filePath,
|
||||||
|
entries: this.ring
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleFlush(): void {
|
||||||
|
if (this.flushTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.flushTimer = setTimeout(() => {
|
||||||
|
this.flushTimer = null;
|
||||||
|
void this.flush();
|
||||||
|
}, FLUSH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeLines(lines: string[]): Promise<void> {
|
||||||
|
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
|
await fsp.appendFile(this.filePath, lines.join(''), 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
11
electron/diagnostics/index.ts
Normal file
11
electron/diagnostics/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
||||||
|
export {
|
||||||
|
attachRendererDiagnosticsHooks,
|
||||||
|
ensurePerfDiagIpcRegistered,
|
||||||
|
getActivePerfDiagWriter,
|
||||||
|
isPerfDiagActive,
|
||||||
|
shutdownPerfDiagnostics,
|
||||||
|
startPerfDiagnostics
|
||||||
|
} from './diagnostics.lifecycle';
|
||||||
|
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';
|
||||||
|
export { PerfDiagWriter } from './diagnostics.writer';
|
||||||
19
electron/diagnostics/process-metrics.rules.ts
Normal file
19
electron/diagnostics/process-metrics.rules.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export interface ProcessWorkingSetSnapshot {
|
||||||
|
workingSetKb: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumWorkingSetKb(processes: readonly ProcessWorkingSetSnapshot[]): number | null {
|
||||||
|
let total = 0;
|
||||||
|
let hasAny = false;
|
||||||
|
|
||||||
|
for (const process of processes) {
|
||||||
|
if (process.workingSetKb == null || process.workingSetKb < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
total += process.workingSetKb;
|
||||||
|
hasAny = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasAny ? total : null;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type DesktopSettings
|
type DesktopSettings
|
||||||
} from '../desktop-settings';
|
} from '../desktop-settings';
|
||||||
import { applyLocalApiSettings, getLocalApiSnapshot } from '../api';
|
import { applyLocalApiSettings, getLocalApiSnapshot } from '../api';
|
||||||
|
import { getProvisionSecret, storeProvisionSecret } from '../api/provision-secret-store';
|
||||||
import {
|
import {
|
||||||
activateLinuxScreenShareAudioRouting,
|
activateLinuxScreenShareAudioRouting,
|
||||||
deactivateLinuxScreenShareAudioRouting,
|
deactivateLinuxScreenShareAudioRouting,
|
||||||
@@ -62,7 +63,11 @@ import { listRunningProcessNames } from '../process-list';
|
|||||||
import { detectActiveGame } from '../game-detection';
|
import { detectActiveGame } from '../game-detection';
|
||||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||||
import { clearAllTokens } from '../api/auth-store';
|
import { clearAllTokens } from '../api/auth-store';
|
||||||
import { assertPathUnderUserData, grantPluginReadRoot, resolveReadablePath } from '../path-jail';
|
import {
|
||||||
|
assertPathUnderUserData,
|
||||||
|
grantPluginReadRoot,
|
||||||
|
resolveReadablePath
|
||||||
|
} from '../path-jail';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
||||||
@@ -380,6 +385,14 @@ export function setupSystemHandlers(): void {
|
|||||||
|
|
||||||
ipcMain.handle('get-app-metrics', () => collectAppMetricsSnapshot());
|
ipcMain.handle('get-app-metrics', () => collectAppMetricsSnapshot());
|
||||||
|
|
||||||
|
ipcMain.handle('store-provision-secret', async (_event, homeUserId: string, secret: string) =>
|
||||||
|
await storeProvisionSecret(homeUserId, secret)
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle('get-provision-secret', async (_event, homeUserId: string) =>
|
||||||
|
await getProvisionSecret(homeUserId)
|
||||||
|
);
|
||||||
|
|
||||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||||
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
|
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
|
||||||
ipcMain.handle('export-user-data', async () => await exportUserData());
|
ipcMain.handle('export-user-data', async () => await exportUserData());
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ describe('path-jail', () => {
|
|||||||
|
|
||||||
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
|
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
|
||||||
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
|
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
|
||||||
|
|
||||||
fs.mkdirSync(bundleDir, { recursive: true });
|
fs.mkdirSync(bundleDir, { recursive: true });
|
||||||
const bundlePath = path.join(bundleDir, 'main.js');
|
const bundlePath = path.join(bundleDir, 'main.js');
|
||||||
|
|
||||||
fs.writeFileSync(bundlePath, 'export default {}');
|
fs.writeFileSync(bundlePath, 'export default {}');
|
||||||
|
|
||||||
await expect(assertPathUnderRoot(tempRoot, bundlePath)).resolves.toBe(bundlePath);
|
await expect(assertPathUnderRoot(tempRoot, bundlePath)).resolves.toBe(bundlePath);
|
||||||
@@ -59,6 +61,7 @@ describe('path-jail', () => {
|
|||||||
it('allows user-granted plugin source roots outside app data', async () => {
|
it('allows user-granted plugin source roots outside app data', async () => {
|
||||||
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-plugin-source-'));
|
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-plugin-source-'));
|
||||||
const manifestPath = path.join(externalRoot, 'plugin-source.json');
|
const manifestPath = path.join(externalRoot, 'plugin-source.json');
|
||||||
|
|
||||||
fs.writeFileSync(manifestPath, '{}');
|
fs.writeFileSync(manifestPath, '{}');
|
||||||
|
|
||||||
grantPluginReadRoot(externalRoot);
|
grantPluginReadRoot(externalRoot);
|
||||||
|
|||||||
@@ -252,6 +252,13 @@ export interface ElectronAPI {
|
|||||||
workingSetKb: number | null;
|
workingSetKb: number | null;
|
||||||
}[];
|
}[];
|
||||||
}>;
|
}>;
|
||||||
|
isPerfDiagEnabled: () => Promise<boolean>;
|
||||||
|
reportPerfDiagSample: (entry: {
|
||||||
|
collectedAt: number;
|
||||||
|
source: 'main' | 'renderer';
|
||||||
|
type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}) => Promise<boolean>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
@@ -339,6 +346,9 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
command: <T = unknown>(command: Command) => Promise<T>;
|
command: <T = unknown>(command: Command) => Promise<T>;
|
||||||
query: <T = unknown>(query: Query) => Promise<T>;
|
query: <T = unknown>(query: Query) => Promise<T>;
|
||||||
|
|
||||||
|
storeProvisionSecret: (homeUserId: string, secret: string) => Promise<boolean>;
|
||||||
|
getProvisionSecret: (homeUserId: string) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
@@ -388,6 +398,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||||
|
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
||||||
|
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||||
@@ -493,7 +505,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
query: (query) => ipcRenderer.invoke('cqrs:query', query),
|
||||||
|
|
||||||
|
storeProvisionSecret: (homeUserId, secret) => ipcRenderer.invoke('store-provision-secret', homeUserId, secret),
|
||||||
|
getProvisionSecret: (homeUserId) => ipcRenderer.invoke('get-provision-secret', homeUserId)
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -52,10 +52,12 @@
|
|||||||
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
|
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
|
||||||
"sort:props": "node tools/sort-template-properties.js",
|
"sort:props": "node tools/sort-template-properties.js",
|
||||||
"i18n:sync": "node tools/sync-app-i18n-catalog.mjs",
|
"i18n:sync": "node tools/sync-app-i18n-catalog.mjs",
|
||||||
"test:e2e": "cd e2e && npx playwright test",
|
"test:e2e": "node e2e/run-playwright.mjs test",
|
||||||
"test:e2e:ui": "cd e2e && npx playwright test --ui",
|
"test:e2e:ui": "node e2e/run-playwright.mjs test --ui",
|
||||||
"test:e2e:debug": "cd e2e && npx playwright test --debug",
|
"test:e2e:debug": "node e2e/run-playwright.mjs test --debug",
|
||||||
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report",
|
"test:e2e:report": "node e2e/run-playwright.mjs show-report ../test-results/html-report",
|
||||||
|
"perf:diag:view": "node tools/perf-diag-viewer.js",
|
||||||
|
"perf:diag:tail": "node tools/perf-diag-viewer.js --tail",
|
||||||
"cap:sync": "cd toju-app && npx cap sync",
|
"cap:sync": "cd toju-app && npx cap sync",
|
||||||
"cap:open:android": "node tools/cap-open-android.js",
|
"cap:open:android": "node tools/cap-open-android.js",
|
||||||
"cap:open:ios": "cd toju-app && npx cap open ios",
|
"cap:open:ios": "cd toju-app && npx cap open ios",
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ Owns the shared, internet-reachable runtime: HTTP routes for server directory /
|
|||||||
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
|
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
|
||||||
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
|
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
|
||||||
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
|
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
|
||||||
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. | "API key", "JWT" |
|
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. Multiple valid tokens may exist per user (multi-device login). | "API key", "JWT" |
|
||||||
|
| **Client instance id** | Opaque per-install string on WebSocket `identify` and `voice_state`; used to distinguish connections for the same `oderId` and to track which connection owns active voice. | "device id" |
|
||||||
|
| **Voice-active connection** | WebSocket connection for a user with `voiceActive=true` after a connected `voice_state`; preferred target for RTC relay. | "voice owner socket" |
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function buildCorsOptions() {
|
|||||||
export function createApp(): express.Express {
|
export function createApp(): express.Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Trust loopback proxies only — avoids express-rate-limit ERR_ERL_PERMISSIVE_TRUST_PROXY.
|
// Trust loopback proxies only - avoids express-rate-limit ERR_ERL_PERMISSIVE_TRUST_PROXY.
|
||||||
app.set('trust proxy', 'loopback');
|
app.set('trust proxy', 'loopback');
|
||||||
app.use(cors(buildCorsOptions()));
|
app.use(cors(buildCorsOptions()));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|||||||
30
server/src/routes/user-registration.rules.spec.ts
Normal file
30
server/src/routes/user-registration.rules.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import { isDuplicateUsernameError } from './user-registration.rules';
|
||||||
|
|
||||||
|
describe('user-registration.rules', () => {
|
||||||
|
it('detects sqlite unique constraint failures on username', () => {
|
||||||
|
expect(isDuplicateUsernameError({
|
||||||
|
message: 'UNIQUE constraint failed: users.username'
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects typeorm query failed errors with username constraint text', () => {
|
||||||
|
expect(isDuplicateUsernameError({
|
||||||
|
name: 'QueryFailedError',
|
||||||
|
message: 'SQLITE_CONSTRAINT: UNIQUE constraint failed: users.username'
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unrelated database errors', () => {
|
||||||
|
expect(isDuplicateUsernameError({
|
||||||
|
message: 'UNIQUE constraint failed: servers.id'
|
||||||
|
})).toBe(false);
|
||||||
|
|
||||||
|
expect(isDuplicateUsernameError(new Error('connection lost'))).toBe(false);
|
||||||
|
expect(isDuplicateUsernameError(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
11
server/src/routes/user-registration.rules.ts
Normal file
11
server/src/routes/user-registration.rules.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function isDuplicateUsernameError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = 'message' in error && typeof error.message === 'string'
|
||||||
|
? error.message
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return message.includes('UNIQUE constraint failed: users.username');
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service';
|
import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service';
|
||||||
import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service';
|
import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service';
|
||||||
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
|
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
|
||||||
|
import { isDuplicateUsernameError } from './user-registration.rules';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -46,7 +47,16 @@ router.post('/register', async (req, res) => {
|
|||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
await registerUser(user);
|
await registerUser(user);
|
||||||
|
} catch (error) {
|
||||||
|
if (isDuplicateUsernameError(error)) {
|
||||||
|
return res.status(409).json({ error: 'Username taken' });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const session = await issueSessionToken(user.id);
|
const session = await issueSessionToken(user.id);
|
||||||
|
|
||||||
res.status(201).json(buildAuthResponse(user, session.token, session.expiresAt));
|
res.status(201).json(buildAuthResponse(user, session.token, session.expiresAt));
|
||||||
|
|||||||
39
server/src/services/session-auth.service.spec.ts
Normal file
39
server/src/services/session-auth.service.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { getSessionTokenTtlMs } from './session-auth.service';
|
||||||
|
|
||||||
|
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
describe('session-auth.service', () => {
|
||||||
|
const originalTtl = process.env.SESSION_TOKEN_TTL_MS;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalTtl === undefined) {
|
||||||
|
delete process.env.SESSION_TOKEN_TTL_MS;
|
||||||
|
} else {
|
||||||
|
process.env.SESSION_TOKEN_TTL_MS = originalTtl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults session tokens to a very long lifetime', () => {
|
||||||
|
delete process.env.SESSION_TOKEN_TTL_MS;
|
||||||
|
|
||||||
|
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors SESSION_TOKEN_TTL_MS when configured', () => {
|
||||||
|
process.env.SESSION_TOKEN_TTL_MS = '3600000';
|
||||||
|
|
||||||
|
expect(getSessionTokenTtlMs()).toBe(3_600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default when SESSION_TOKEN_TTL_MS is invalid', () => {
|
||||||
|
process.env.SESSION_TOKEN_TTL_MS = 'not-a-number';
|
||||||
|
|
||||||
|
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { SessionTokenEntity } from '../entities/SessionTokenEntity';
|
|||||||
import { getUserById } from '../cqrs';
|
import { getUserById } from '../cqrs';
|
||||||
import type { AuthUserPayload } from '../cqrs/types';
|
import type { AuthUserPayload } from '../cqrs/types';
|
||||||
|
|
||||||
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export interface IssuedSessionToken {
|
export interface IssuedSessionToken {
|
||||||
token: string;
|
token: string;
|
||||||
|
|||||||
106
server/src/websocket/broadcast.spec.ts
Normal file
106
server/src/websocket/broadcast.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { connectedUsers } from './state';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
import {
|
||||||
|
broadcastToServer,
|
||||||
|
findUserByOderId,
|
||||||
|
findVoiceActiveConnection
|
||||||
|
} from './broadcast';
|
||||||
|
|
||||||
|
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const ws = {
|
||||||
|
readyState: WebSocket.OPEN,
|
||||||
|
send: (data: string) => { sent.push(data); },
|
||||||
|
close: () => {},
|
||||||
|
sentMessages: sent
|
||||||
|
} as unknown as WebSocket & { sentMessages: string[] };
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedUser(
|
||||||
|
connectionId: string,
|
||||||
|
oderId: string,
|
||||||
|
overrides: Partial<ConnectedUser> = {}
|
||||||
|
): ConnectedUser {
|
||||||
|
const user: ConnectedUser = {
|
||||||
|
oderId,
|
||||||
|
ws: createMockWs(),
|
||||||
|
authenticated: true,
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
displayName: 'Test User',
|
||||||
|
lastPong: Date.now(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('broadcastToServer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivers chat_message to every connection in the server except the sender connection', () => {
|
||||||
|
createConnectedUser('conn-a1', 'user-1');
|
||||||
|
const connA2 = createConnectedUser('conn-a2', 'user-1');
|
||||||
|
const connB = createConnectedUser('conn-b', 'user-2');
|
||||||
|
|
||||||
|
broadcastToServer('server-1', { type: 'chat_message', text: 'hello' }, {
|
||||||
|
excludeConnectionId: 'conn-a1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||||
|
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||||
|
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
|
||||||
|
expect((connectedUsers.get('conn-a1')!.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
|
||||||
|
const connA1 = createConnectedUser('conn-a1', 'user-1');
|
||||||
|
const connA2 = createConnectedUser('conn-a2', 'user-1');
|
||||||
|
const connB = createConnectedUser('conn-b', 'user-2');
|
||||||
|
|
||||||
|
broadcastToServer('server-1', { type: 'user_left', oderId: 'user-1' }, {
|
||||||
|
excludeIdentityOderId: 'user-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((connA1.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||||
|
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||||
|
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findVoiceActiveConnection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the connection marked voiceActive for the user', () => {
|
||||||
|
createConnectedUser('conn-passive', 'user-1', { voiceActive: false });
|
||||||
|
const active = createConnectedUser('conn-active', 'user-1', { voiceActive: true });
|
||||||
|
|
||||||
|
expect(findVoiceActiveConnection('user-1')).toBe(active);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when no voiceActive connection exists', () => {
|
||||||
|
createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
expect(findVoiceActiveConnection('user-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findUserByOderId falls back to any open connection when no voiceActive connection exists', () => {
|
||||||
|
const fallback = createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
expect(findUserByOderId('user-1')).toBe(fallback);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,19 +7,35 @@ interface WsMessage {
|
|||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void {
|
export interface BroadcastOptions {
|
||||||
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
/** Skip only the sending WebSocket connection. */
|
||||||
|
excludeConnectionId?: string;
|
||||||
|
/** Skip every open connection for this identity (presence events). */
|
||||||
|
excludeIdentityOderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Deduplicate by oderId so users with multiple connections (e.g. from
|
export function broadcastToServer(serverId: string, message: WsMessage, options?: BroadcastOptions): void {
|
||||||
// different signal URLs routing to the same server) receive the
|
console.log(
|
||||||
// broadcast only once.
|
`Broadcasting to server ${serverId}, excluding connection ${options?.excludeConnectionId ?? 'none'} ` +
|
||||||
const sentToOderIds = new Set<string>();
|
`identity ${options?.excludeIdentityOderId ?? 'none'}:`,
|
||||||
|
message.type
|
||||||
|
);
|
||||||
|
|
||||||
connectedUsers.forEach((user) => {
|
connectedUsers.forEach((user, connectionId) => {
|
||||||
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId && !sentToOderIds.has(user.oderId)) {
|
if (
|
||||||
sentToOderIds.add(user.oderId);
|
!user.serverIds.has(serverId)
|
||||||
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
|| connectionId === options?.excludeConnectionId
|
||||||
|
|| (options?.excludeIdentityOderId && user.oderId === options.excludeIdentityOderId)
|
||||||
|
|| user.ws.readyState !== WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(` -> Sending to ${user.displayName} (${user.oderId}) via ${connectionId}`);
|
||||||
user.ws.send(JSON.stringify(message));
|
user.ws.send(JSON.stringify(message));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to broadcast ${message.type} to ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -77,7 +93,45 @@ export function notifyUser(oderId: string, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notifyOtherConnectionsForOderId(
|
||||||
|
oderId: string,
|
||||||
|
message: WsMessage,
|
||||||
|
excludeConnectionId?: string
|
||||||
|
): void {
|
||||||
|
connectedUsers.forEach((user, connectionId) => {
|
||||||
|
if (
|
||||||
|
connectionId === excludeConnectionId
|
||||||
|
|| user.oderId !== oderId
|
||||||
|
|| user.ws.readyState !== WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
user.ws.send(JSON.stringify(message));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to notify ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function findUserByOderId(oderId: string) {
|
export function findUserByOderId(oderId: string) {
|
||||||
|
return findVoiceActiveConnection(oderId) ?? findAnyConnectionForOderId(oderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findVoiceActiveConnection(oderId: string): ConnectedUser | undefined {
|
||||||
|
let voiceActiveMatch: ConnectedUser | undefined;
|
||||||
|
|
||||||
|
connectedUsers.forEach((user) => {
|
||||||
|
if (user.oderId === oderId && user.voiceActive && user.ws.readyState === WebSocket.OPEN) {
|
||||||
|
voiceActiveMatch = user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return voiceActiveMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAnyConnectionForOderId(oderId: string): ConnectedUser | undefined {
|
||||||
let match: ConnectedUser | undefined;
|
let match: ConnectedUser | undefined;
|
||||||
|
|
||||||
connectedUsers.forEach((user) => {
|
connectedUsers.forEach((user) => {
|
||||||
|
|||||||
223
server/src/websocket/handler-multi-client.spec.ts
Normal file
223
server/src/websocket/handler-multi-client.spec.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { connectedUsers } from './state';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
|
vi.mock('../services/server-access.service', () => ({
|
||||||
|
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })),
|
||||||
|
findServerMembership: vi.fn(async () => ({ id: 'membership-1' })),
|
||||||
|
usersShareServerMembership: vi.fn(async () => true)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/session-auth.service', () => ({
|
||||||
|
consumeSessionToken: vi.fn(async (token: string) => {
|
||||||
|
if (token !== 'test-token') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
createdAt: Date.now()
|
||||||
|
},
|
||||||
|
issuedAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 60_000
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/plugin-support.service', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../services/plugin-support.service')>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getPluginRequirementsSnapshot: vi.fn(async () => ({
|
||||||
|
requirements: [],
|
||||||
|
eventDefinitions: []
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMockWs(): WebSocket & { sentMessages: string[]; closeCalled: boolean } {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const ws = {
|
||||||
|
readyState: WebSocket.OPEN,
|
||||||
|
send: (data: string) => { sent.push(data); },
|
||||||
|
close: () => { ws.closeCalled = true; },
|
||||||
|
terminate: () => { ws.closeCalled = true; },
|
||||||
|
closeCalled: false,
|
||||||
|
sentMessages: sent
|
||||||
|
} as unknown as WebSocket & { sentMessages: string[]; closeCalled: boolean };
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedUser(
|
||||||
|
connectionId: string,
|
||||||
|
overrides: Partial<ConnectedUser> = {}
|
||||||
|
): ConnectedUser {
|
||||||
|
const ws = createMockWs();
|
||||||
|
const user: ConnectedUser = {
|
||||||
|
oderId: connectionId,
|
||||||
|
ws,
|
||||||
|
authenticated: false,
|
||||||
|
serverIds: new Set(),
|
||||||
|
displayName: 'Alice',
|
||||||
|
lastPong: Date.now(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSentMessages(user: ConnectedUser): string[] {
|
||||||
|
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('server websocket handler - multi-client sessions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relays voice_state to other connections for the same user', async () => {
|
||||||
|
const sender = createConnectedUser('conn-a1', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-1',
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
clientInstanceId: 'device-a'
|
||||||
|
});
|
||||||
|
const passive = createConnectedUser('conn-a2', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-1',
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
clientInstanceId: 'device-b'
|
||||||
|
});
|
||||||
|
|
||||||
|
getSentMessages(passive).length = 0;
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-a1', {
|
||||||
|
type: 'voice_state',
|
||||||
|
serverId: 'server-1',
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
roomId: 'voice-1',
|
||||||
|
serverId: 'server-1',
|
||||||
|
clientInstanceId: 'device-a'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessages(passive).map((raw) => JSON.parse(raw) as { type: string });
|
||||||
|
const voiceState = messages.find((message) => message.type === 'voice_state');
|
||||||
|
|
||||||
|
expect(voiceState).toBeDefined();
|
||||||
|
expect(connectedUsers.get('conn-a1')?.voiceActive).toBe(true);
|
||||||
|
expect(connectedUsers.get('conn-a2')?.voiceActive).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards RTC offers to the voice-active connection for the target user', async () => {
|
||||||
|
const sender = createConnectedUser('conn-sender', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-2',
|
||||||
|
serverIds: new Set(['server-1'])
|
||||||
|
});
|
||||||
|
|
||||||
|
createConnectedUser('conn-passive', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-1',
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
clientInstanceId: 'device-passive'
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = createConnectedUser('conn-active', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-1',
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
voiceActive: true,
|
||||||
|
clientInstanceId: 'device-active'
|
||||||
|
});
|
||||||
|
|
||||||
|
getSentMessages(active).length = 0;
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-sender', {
|
||||||
|
type: 'offer',
|
||||||
|
targetUserId: 'user-1',
|
||||||
|
serverId: 'server-1',
|
||||||
|
payload: { sdp: { type: 'offer', sdp: 'v=0' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string });
|
||||||
|
|
||||||
|
expect(messages.some((message) => message.type === 'offer')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relays voice_client_takeover to other connections for the same user', async () => {
|
||||||
|
createConnectedUser('conn-requester', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-1',
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
clientInstanceId: 'device-b'
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = createConnectedUser('conn-active', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-1',
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
voiceActive: true,
|
||||||
|
clientInstanceId: 'device-a'
|
||||||
|
});
|
||||||
|
|
||||||
|
getSentMessages(active).length = 0;
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-requester', {
|
||||||
|
type: 'voice_client_takeover',
|
||||||
|
clientInstanceId: 'device-b'
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string; clientInstanceId?: string });
|
||||||
|
const takeover = messages.find((message) => message.type === 'voice_client_takeover');
|
||||||
|
|
||||||
|
expect(takeover?.clientInstanceId).toBe('device-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts a stale connection with the same identity scope and client instance', async () => {
|
||||||
|
const stale = createConnectedUser('conn-stale', {
|
||||||
|
authenticated: true,
|
||||||
|
oderId: 'user-1',
|
||||||
|
connectionScope: 'ws://localhost:3001',
|
||||||
|
clientInstanceId: 'device-a'
|
||||||
|
});
|
||||||
|
|
||||||
|
createConnectedUser('conn-new', {
|
||||||
|
authenticated: false,
|
||||||
|
connectionScope: 'ws://localhost:3001',
|
||||||
|
clientInstanceId: 'device-a'
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-new', {
|
||||||
|
type: 'identify',
|
||||||
|
token: 'test-token',
|
||||||
|
oderId: 'user-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
connectionScope: 'ws://localhost:3001',
|
||||||
|
clientInstanceId: 'device-a'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(connectedUsers.has('conn-stale')).toBe(false);
|
||||||
|
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
|
||||||
|
expect(connectedUsers.get('conn-new')?.authenticated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
findUserByOderId,
|
findUserByOderId,
|
||||||
getServerIdsForOderId,
|
getServerIdsForOderId,
|
||||||
getUniqueUsersInServer,
|
getUniqueUsersInServer,
|
||||||
isOderIdConnectedToServer
|
isOderIdConnectedToServer,
|
||||||
|
notifyOtherConnectionsForOderId
|
||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import {
|
import {
|
||||||
authorizeWebSocketJoin,
|
authorizeWebSocketJoin,
|
||||||
@@ -72,6 +73,74 @@ function buildPresenceUserPayload(user: ConnectedUser): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeClientInstanceId(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVoiceConnected(message: WsMessage): boolean {
|
||||||
|
const voiceState = message['voiceState'];
|
||||||
|
|
||||||
|
if (!voiceState || typeof voiceState !== 'object') {
|
||||||
|
return message['isConnected'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (voiceState as { isConnected?: boolean }).isConnected === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictStaleClientInstanceConnections(
|
||||||
|
oderId: string,
|
||||||
|
connectionScope: string | undefined,
|
||||||
|
clientInstanceId: string | undefined,
|
||||||
|
keepConnectionId: string
|
||||||
|
): void {
|
||||||
|
if (!clientInstanceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedUsers.forEach((candidate, connectionId) => {
|
||||||
|
if (
|
||||||
|
connectionId === keepConnectionId
|
||||||
|
|| candidate.oderId !== oderId
|
||||||
|
|| candidate.connectionScope !== connectionScope
|
||||||
|
|| candidate.clientInstanceId !== clientInstanceId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
candidate.ws.close();
|
||||||
|
} catch {
|
||||||
|
console.warn(`Failed to close stale connection ${connectionId} for ${oderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedUsers.delete(connectionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string): void {
|
||||||
|
connectedUsers.forEach((candidate, connectionId) => {
|
||||||
|
if (candidate.oderId !== oderId || connectionId === exceptConnectionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.voiceActive = false;
|
||||||
|
connectedUsers.set(connectionId, candidate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'voice_state',
|
||||||
|
...snapshot
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function readMessageId(value: unknown): string | undefined {
|
function readMessageId(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -198,13 +267,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
|||||||
|
|
||||||
const newOderId = session.user.id;
|
const newOderId = session.user.id;
|
||||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||||
|
const newClientInstanceId = normalizeClientInstanceId(message['clientInstanceId']);
|
||||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||||
const previousDescription = user.description;
|
const previousDescription = user.description;
|
||||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||||
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
||||||
|
|
||||||
|
evictStaleClientInstanceConnections(newOderId, newScope, newClientInstanceId, connectionId);
|
||||||
|
|
||||||
user.oderId = newOderId;
|
user.oderId = newOderId;
|
||||||
user.authenticated = true;
|
user.authenticated = true;
|
||||||
|
user.clientInstanceId = newClientInstanceId;
|
||||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||||
@@ -223,6 +296,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
|||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
|
|
||||||
|
const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) =>
|
||||||
|
otherConnectionId !== connectionId
|
||||||
|
&& otherUser.oderId === newOderId
|
||||||
|
&& otherUser.voiceActive
|
||||||
|
&& otherUser.voiceStateSnapshot
|
||||||
|
)?.[1]?.voiceStateSnapshot;
|
||||||
|
|
||||||
|
if (voiceSnapshot) {
|
||||||
|
sendVoiceStateSnapshotToConnection(user, voiceSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
user.displayName === previousDisplayName
|
user.displayName === previousDisplayName
|
||||||
&& user.description === previousDescription
|
&& user.description === previousDescription
|
||||||
@@ -240,7 +324,7 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
|||||||
...buildPresenceUserPayload(user),
|
...buildPresenceUserPayload(user),
|
||||||
serverId
|
serverId
|
||||||
},
|
},
|
||||||
user.oderId
|
{ excludeConnectionId: connectionId }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,7 +371,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
...buildPresenceUserPayload(user),
|
...buildPresenceUserPayload(user),
|
||||||
serverId: sid
|
serverId: sid
|
||||||
},
|
},
|
||||||
user.oderId
|
{ excludeIdentityOderId: user.oderId }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,7 +422,7 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
serverId: leaveSid,
|
serverId: leaveSid,
|
||||||
serverIds: remainingServerIds
|
serverIds: remainingServerIds
|
||||||
},
|
},
|
||||||
user.oderId
|
{ excludeIdentityOderId: user.oderId }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +478,7 @@ async function forwardRtcMessage(user: ConnectedUser, message: WsMessage): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
function handleChatMessage(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||||
|
|
||||||
if (chatSid && user.serverIds.has(chatSid)) {
|
if (chatSid && user.serverIds.has(chatSid)) {
|
||||||
@@ -404,18 +488,38 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
|||||||
message: message['message'],
|
message: message['message'],
|
||||||
senderId: user.oderId,
|
senderId: user.oderId,
|
||||||
senderName: user.displayName,
|
senderName: user.displayName,
|
||||||
|
clientInstanceId: user.clientInstanceId,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
}, { excludeConnectionId: connectionId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
|
function handleVoiceState(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||||
|
|
||||||
if (!serverId || !user.serverIds.has(serverId)) {
|
if (!serverId || !user.serverIds.has(serverId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isConnected = readVoiceConnected(message);
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
clearVoiceActiveForOderId(user.oderId, connectionId);
|
||||||
|
user.voiceActive = true;
|
||||||
|
user.voiceStateSnapshot = {
|
||||||
|
...message,
|
||||||
|
type: 'voice_state',
|
||||||
|
serverId,
|
||||||
|
oderId: user.oderId,
|
||||||
|
displayName: normalizeDisplayName(user.displayName)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
user.voiceActive = false;
|
||||||
|
user.voiceStateSnapshot = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
broadcastToServer(
|
broadcastToServer(
|
||||||
serverId,
|
serverId,
|
||||||
{
|
{
|
||||||
@@ -425,11 +529,19 @@ function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
|
|||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: normalizeDisplayName(user.displayName)
|
displayName: normalizeDisplayName(user.displayName)
|
||||||
},
|
},
|
||||||
user.oderId
|
{ excludeConnectionId: connectionId }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
function handleVoiceClientTakeover(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
|
notifyOtherConnectionsForOderId(user.oderId, {
|
||||||
|
type: 'voice_client_takeover',
|
||||||
|
clientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId,
|
||||||
|
requestedByClientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId
|
||||||
|
}, connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
|
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
|
||||||
const isTyping = message['isTyping'] !== false;
|
const isTyping = message['isTyping'] !== false;
|
||||||
@@ -443,9 +555,10 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
|||||||
channelId,
|
channelId,
|
||||||
isTyping,
|
isTyping,
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName
|
displayName: user.displayName,
|
||||||
|
clientInstanceId: user.clientInstanceId
|
||||||
},
|
},
|
||||||
user.oderId
|
{ excludeConnectionId: connectionId }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,7 +588,7 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
|
|||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
status
|
status
|
||||||
},
|
},
|
||||||
user.oderId
|
{ excludeConnectionId: connectionId }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,7 +633,7 @@ function handleServerIconSyncRequest(user: ConnectedUser, message: WsMessage): v
|
|||||||
user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
|
async function handlePluginEvent(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||||
const pluginId = readMessageId(message['pluginId']);
|
const pluginId = readMessageId(message['pluginId']);
|
||||||
const eventName = readMessageId(message['eventName']);
|
const eventName = readMessageId(message['eventName']);
|
||||||
@@ -565,7 +678,7 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
|
|||||||
sourceUserId: user.oderId,
|
sourceUserId: user.oderId,
|
||||||
emittedAt: Date.now()
|
emittedAt: Date.now()
|
||||||
},
|
},
|
||||||
user.oderId
|
{ excludeConnectionId: connectionId }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendPluginError(user, error, message);
|
sendPluginError(user, error, message);
|
||||||
@@ -623,15 +736,19 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'chat_message':
|
case 'chat_message':
|
||||||
handleChatMessage(user, message);
|
handleChatMessage(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'voice_state':
|
case 'voice_state':
|
||||||
handleVoiceState(user, message);
|
handleVoiceState(user, message, connectionId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'voice_client_takeover':
|
||||||
|
handleVoiceClientTakeover(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'typing':
|
case 'typing':
|
||||||
handleTyping(user, message);
|
handleTyping(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'status_update':
|
case 'status_update':
|
||||||
@@ -647,7 +764,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'plugin_event':
|
case 'plugin_event':
|
||||||
await handlePluginEvent(user, message);
|
await handlePluginEvent(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function removeDeadConnection(connectionId: string): void {
|
|||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
serverId: sid,
|
serverId: sid,
|
||||||
serverIds: remainingServerIds
|
serverIds: remainingServerIds
|
||||||
}, user.oderId);
|
}, { excludeIdentityOderId: user.oderId });
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export interface ConnectedUser {
|
|||||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||||
/** Latest server icon timestamp this connection can provide over P2P. */
|
/** Latest server icon timestamp this connection can provide over P2P. */
|
||||||
serverIconUpdatedAtByServerId?: Map<string, number>;
|
serverIconUpdatedAtByServerId?: Map<string, number>;
|
||||||
|
/** Stable per-install client id sent by the product client. */
|
||||||
|
clientInstanceId?: string;
|
||||||
|
/** Whether this connection currently owns active voice/WebRTC for the user. */
|
||||||
|
voiceActive?: boolean;
|
||||||
|
/** Cached voice state snapshot used to bootstrap newly connected client instances. */
|
||||||
|
voiceStateSnapshot?: Record<string, unknown>;
|
||||||
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
||||||
lastPong: number;
|
lastPong: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra
|
|||||||
| **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" |
|
| **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" |
|
||||||
| **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" |
|
| **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" |
|
||||||
| **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" |
|
| **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" |
|
||||||
|
| **Client instance** | Stable per-tab UUID (`metoyou.clientInstanceId` in `sessionStorage`) sent on WebSocket `identify` and voice-state payloads so the signaling server can route multi-device sessions without evicting other tabs or synced profiles. | "device id", "session id" |
|
||||||
|
| **Voice owner connection** | The single client instance whose `clientInstanceId` matches the user's active `voiceState.clientInstanceId` and therefore owns mic/WebRTC for that identity. | "active voice client" |
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,15 @@
|
|||||||
"prepareStateFailed": "Failed to prepare local user state.",
|
"prepareStateFailed": "Failed to prepare local user state.",
|
||||||
"noCurrentUser": "No current user",
|
"noCurrentUser": "No current user",
|
||||||
"sessionExpired": "Your session expired. Please sign in again."
|
"sessionExpired": "Your session expired. Please sign in again."
|
||||||
|
},
|
||||||
|
"authorize": {
|
||||||
|
"title": "Sign in to {{serverName}}",
|
||||||
|
"registerTitle": "Create account on {{serverName}}",
|
||||||
|
"description": "Your home account stays signed in. This only authorizes the selected signal server.",
|
||||||
|
"defaultServerName": "Signal Server"
|
||||||
|
},
|
||||||
|
"provision": {
|
||||||
|
"usernameCollision": "Username {{preferredUsername}} was taken on {{serverName}}. Created {{provisionedUsername}} instead."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
"resizeChat": "Resize chat",
|
"resizeChat": "Resize chat",
|
||||||
"yourCamera": "Your camera",
|
"yourCamera": "Your camera",
|
||||||
"yourScreen": "Your screen",
|
"yourScreen": "Your screen",
|
||||||
"waiting": "Waiting"
|
"waiting": "Waiting",
|
||||||
|
"voiceOnOtherDevice": "Active on another device"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"inProgress": "Call in progress"
|
"inProgress": "Call in progress"
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
"latencyMs": "{{ms}} ms",
|
"latencyMs": "{{ms}} ms",
|
||||||
"playing": "Playing {{game}}",
|
"playing": "Playing {{game}}",
|
||||||
"inVoice": "In voice",
|
"inVoice": "In voice",
|
||||||
|
"voiceOnOtherDevice": "In voice on another device",
|
||||||
|
"takeOverVoice": "Join",
|
||||||
"plugins": "Plugins",
|
"plugins": "Plugins",
|
||||||
"viewPlugins": "View plugins",
|
"viewPlugins": "View plugins",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
"loading": "Loading invite...",
|
"loading": "Loading invite...",
|
||||||
"joining": "Joining {{name}}...",
|
"joining": "Joining {{name}}...",
|
||||||
"redirectingLogin": "Redirecting to login...",
|
"redirectingLogin": "Redirecting to login...",
|
||||||
|
"redirectingAuthorize": "Authorizing signal server...",
|
||||||
"missingInfo": "This invite link is missing required server information.",
|
"missingInfo": "This invite link is missing required server information.",
|
||||||
"acceptFailed": "Unable to accept this invite.",
|
"acceptFailed": "Unable to accept this invite.",
|
||||||
"banned": "You are banned from this server and cannot accept this invite.",
|
"banned": "You are banned from this server and cannot accept this invite.",
|
||||||
|
|||||||
@@ -93,6 +93,11 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Please enter a valid URL",
|
"invalidUrl": "Please enter a valid URL",
|
||||||
"duplicateUrl": "This server URL already exists"
|
"duplicateUrl": "This server URL already exists"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"authorized": "Authorized",
|
||||||
|
"needsSignIn": "Needs sign-in",
|
||||||
|
"signIn": "Sign in"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
@@ -116,6 +121,7 @@
|
|||||||
"turnUser": "User: {{username}}",
|
"turnUser": "User: {{username}}",
|
||||||
"moveUp": "Move up (higher priority)",
|
"moveUp": "Move up (higher priority)",
|
||||||
"moveDown": "Move down (lower priority)",
|
"moveDown": "Move down (lower priority)",
|
||||||
|
"remove": "Remove",
|
||||||
"empty": "No ICE servers configured. P2P connections may fail across networks.",
|
"empty": "No ICE servers configured. P2P connections may fail across networks.",
|
||||||
"addTitle": "Add ICE Server",
|
"addTitle": "Add ICE Server",
|
||||||
"stunPlaceholder": "stun:stun.example.com:19302",
|
"stunPlaceholder": "stun:stun.example.com:19302",
|
||||||
|
|||||||
@@ -59,6 +59,15 @@
|
|||||||
"prepareStateFailed": "Failed to prepare local user state.",
|
"prepareStateFailed": "Failed to prepare local user state.",
|
||||||
"noCurrentUser": "No current user",
|
"noCurrentUser": "No current user",
|
||||||
"sessionExpired": "Your session expired. Please sign in again."
|
"sessionExpired": "Your session expired. Please sign in again."
|
||||||
|
},
|
||||||
|
"authorize": {
|
||||||
|
"title": "Sign in to {{serverName}}",
|
||||||
|
"registerTitle": "Create account on {{serverName}}",
|
||||||
|
"description": "Your home account stays signed in. This only authorizes the selected signal server.",
|
||||||
|
"defaultServerName": "Signal Server"
|
||||||
|
},
|
||||||
|
"provision": {
|
||||||
|
"usernameCollision": "Username {{preferredUsername}} was taken on {{serverName}}. Created {{provisionedUsername}} instead."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"call": {
|
"call": {
|
||||||
@@ -97,7 +106,8 @@
|
|||||||
"resizeChat": "Resize chat",
|
"resizeChat": "Resize chat",
|
||||||
"yourCamera": "Your camera",
|
"yourCamera": "Your camera",
|
||||||
"yourScreen": "Your screen",
|
"yourScreen": "Your screen",
|
||||||
"waiting": "Waiting"
|
"waiting": "Waiting",
|
||||||
|
"voiceOnOtherDevice": "Active on another device"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"inProgress": "Call in progress"
|
"inProgress": "Call in progress"
|
||||||
@@ -768,6 +778,8 @@
|
|||||||
"latencyMs": "{{ms}} ms",
|
"latencyMs": "{{ms}} ms",
|
||||||
"playing": "Playing {{game}}",
|
"playing": "Playing {{game}}",
|
||||||
"inVoice": "In voice",
|
"inVoice": "In voice",
|
||||||
|
"voiceOnOtherDevice": "In voice on another device",
|
||||||
|
"takeOverVoice": "Join",
|
||||||
"plugins": "Plugins",
|
"plugins": "Plugins",
|
||||||
"viewPlugins": "View plugins",
|
"viewPlugins": "View plugins",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
@@ -1010,6 +1022,7 @@
|
|||||||
"loading": "Loading invite...",
|
"loading": "Loading invite...",
|
||||||
"joining": "Joining {{name}}...",
|
"joining": "Joining {{name}}...",
|
||||||
"redirectingLogin": "Redirecting to login...",
|
"redirectingLogin": "Redirecting to login...",
|
||||||
|
"redirectingAuthorize": "Authorizing signal server...",
|
||||||
"missingInfo": "This invite link is missing required server information.",
|
"missingInfo": "This invite link is missing required server information.",
|
||||||
"acceptFailed": "Unable to accept this invite.",
|
"acceptFailed": "Unable to accept this invite.",
|
||||||
"banned": "You are banned from this server and cannot accept this invite.",
|
"banned": "You are banned from this server and cannot accept this invite.",
|
||||||
@@ -1135,6 +1148,11 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Please enter a valid URL",
|
"invalidUrl": "Please enter a valid URL",
|
||||||
"duplicateUrl": "This server URL already exists"
|
"duplicateUrl": "This server URL already exists"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"authorized": "Authorized",
|
||||||
|
"needsSignIn": "Needs sign-in",
|
||||||
|
"signIn": "Sign in"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
@@ -1158,6 +1176,7 @@
|
|||||||
"turnUser": "User: {{username}}",
|
"turnUser": "User: {{username}}",
|
||||||
"moveUp": "Move up (higher priority)",
|
"moveUp": "Move up (higher priority)",
|
||||||
"moveDown": "Move down (lower priority)",
|
"moveDown": "Move down (lower priority)",
|
||||||
|
"remove": "Remove",
|
||||||
"empty": "No ICE servers configured. P2P connections may fail across networks.",
|
"empty": "No ICE servers configured. P2P connections may fail across networks.",
|
||||||
"addTitle": "Add ICE Server",
|
"addTitle": "Add ICE Server",
|
||||||
"stunPlaceholder": "stun:stun.example.com:19302",
|
"stunPlaceholder": "stun:stun.example.com:19302",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { RoomsActions } from './store/rooms/rooms.actions';
|
|||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
import { ROOM_URL_PATTERN } from './core/constants';
|
import { ROOM_URL_PATTERN } from './core/constants';
|
||||||
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
||||||
|
import { buildLoginReturnQueryParams } from './domains/authentication/domain/logic/auth-navigation.rules';
|
||||||
import { runWhenIdle } from './shared/rxjs';
|
import { runWhenIdle } from './shared/rxjs';
|
||||||
import {
|
import {
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
@@ -319,9 +320,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
|
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
this.router.navigate(['/login'], {
|
this.router.navigate(['/login'], {
|
||||||
queryParams: {
|
queryParams: buildLoginReturnQueryParams(currentUrl)
|
||||||
returnUrl: currentUrl
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { ClientInstanceService } from './client-instance.service';
|
||||||
|
|
||||||
|
const SESSION_STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||||
|
const LEGACY_LOCAL_STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||||
|
|
||||||
|
describe('ClientInstanceService', () => {
|
||||||
|
const sessionStorage = new Map<string, string>();
|
||||||
|
const localStorage = new Map<string, string>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
vi.stubGlobal('sessionStorage', {
|
||||||
|
getItem: (key: string) => sessionStorage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { sessionStorage.set(key, value); },
|
||||||
|
removeItem: (key: string) => { sessionStorage.delete(key); }
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (key: string) => localStorage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { localStorage.set(key, value); },
|
||||||
|
removeItem: (key: string) => { localStorage.delete(key); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates and persists a stable id for the same tab session', () => {
|
||||||
|
const service = new ClientInstanceService();
|
||||||
|
const first = service.getClientInstanceId();
|
||||||
|
const second = new ClientInstanceService().getClientInstanceId();
|
||||||
|
|
||||||
|
expect(first).toMatch(/^[0-9a-f-]{36}$/i);
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(sessionStorage.get(SESSION_STORAGE_KEY)).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses independent ids across separate tab sessions', () => {
|
||||||
|
sessionStorage.set(SESSION_STORAGE_KEY, 'tab-a');
|
||||||
|
|
||||||
|
const tabA = new ClientInstanceService().getClientInstanceId();
|
||||||
|
|
||||||
|
sessionStorage.clear();
|
||||||
|
sessionStorage.set(SESSION_STORAGE_KEY, 'tab-b');
|
||||||
|
|
||||||
|
const tabB = new ClientInstanceService().getClientInstanceId();
|
||||||
|
|
||||||
|
expect(tabA).toBe('tab-a');
|
||||||
|
expect(tabB).toBe('tab-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse legacy localStorage ids that collide across tabs or synced browsers', () => {
|
||||||
|
localStorage.set(LEGACY_LOCAL_STORAGE_KEY, 'synced-device-id');
|
||||||
|
|
||||||
|
const id = new ClientInstanceService().getClientInstanceId();
|
||||||
|
|
||||||
|
expect(id).not.toBe('synced-device-id');
|
||||||
|
expect(sessionStorage.get(SESSION_STORAGE_KEY)).toBe(id);
|
||||||
|
expect(localStorage.has(LEGACY_LOCAL_STORAGE_KEY)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
toju-app/src/app/core/platform/client-instance.service.ts
Normal file
63
toju-app/src/app/core/platform/client-instance.service.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
const SESSION_STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||||
|
const LEGACY_LOCAL_STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable id for this browser tab/window session.
|
||||||
|
*
|
||||||
|
* Stored in sessionStorage so multiple tabs or synced browser profiles do not
|
||||||
|
* share the same id and evict each other on the signaling server.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClientInstanceService {
|
||||||
|
private cachedId: string | null = null;
|
||||||
|
|
||||||
|
getClientInstanceId(): string {
|
||||||
|
if (this.cachedId) {
|
||||||
|
return this.cachedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = this.readStoredId();
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
this.cachedId = stored;
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearLegacyLocalStorageId();
|
||||||
|
|
||||||
|
const created = crypto.randomUUID();
|
||||||
|
|
||||||
|
this.writeStoredId(created);
|
||||||
|
this.cachedId = created;
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStoredId(): string | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY)?.trim();
|
||||||
|
|
||||||
|
return raw || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeStoredId(id: string): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
|
||||||
|
} catch {
|
||||||
|
// Ignore quota / private-mode failures; in-memory cache still works for this session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearLegacyLocalStorageId(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(LEGACY_LOCAL_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage access failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,6 +244,13 @@ export interface ElectronAppMetricsSnapshot {
|
|||||||
processes: ElectronAppMetricsProcess[];
|
processes: ElectronAppMetricsProcess[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElectronPerfDiagEntry {
|
||||||
|
collectedAt: number;
|
||||||
|
source: 'main' | 'renderer';
|
||||||
|
type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronApi {
|
export interface ElectronApi {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -263,6 +270,8 @@ export interface ElectronApi {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||||
|
isPerfDiagEnabled?: () => Promise<boolean>;
|
||||||
|
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
@@ -314,6 +323,8 @@ export interface ElectronApi {
|
|||||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||||
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
||||||
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
||||||
|
storeProvisionSecret?: (homeUserId: string, secret: string) => Promise<boolean>;
|
||||||
|
getProvisionSecret?: (homeUserId: string) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ElectronWindow = Window & {
|
export type ElectronWindow = Window & {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './platform.service';
|
export * from './platform.service';
|
||||||
export * from './external-link.service';
|
export * from './external-link.service';
|
||||||
export * from './viewport.service';
|
export * from './viewport.service';
|
||||||
|
export * from './client-instance.service';
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ authentication/
|
|||||||
│ └── authentication.model.ts LoginResponse interface
|
│ └── authentication.model.ts LoginResponse interface
|
||||||
│
|
│
|
||||||
├── feature/
|
├── feature/
|
||||||
│ ├── login/ Login form component
|
│ ├── login/ Login form (`<form ngSubmit>`; autofocus + select-on-focus via shared directives)
|
||||||
│ ├── register/ Registration form component
|
│ ├── register/ Registration form (same form-field UX as login)
|
||||||
│ └── user-bar/ Displays current user or login/register links
|
│ └── user-bar/ Displays current user or login/register links
|
||||||
│
|
│
|
||||||
└── index.ts Barrel exports
|
└── index.ts Barrel exports
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export class AuthTokenStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getToken(serverUrl: string): string | null {
|
getToken(serverUrl: string): string | null {
|
||||||
|
const entry = this.getTokenEntry(serverUrl);
|
||||||
|
|
||||||
|
return entry?.token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTokenEntry(serverUrl: string): StoredAuthToken | null {
|
||||||
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||||
const entry = this.readStore()[normalizedUrl];
|
const entry = this.readStore()[normalizedUrl];
|
||||||
|
|
||||||
@@ -30,7 +36,7 @@ export class AuthTokenStoreService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry.token;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearToken(serverUrl: string): void {
|
clearToken(serverUrl: string): void {
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ export class AuthenticationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private persistSessionToken(serverId: string | undefined, response: LoginResponse): void {
|
private persistSessionToken(serverId: string | undefined, response: LoginResponse): void {
|
||||||
this.authTokenStore.setToken(this.resolveServerUrl(serverId), response.token, response.expiresAt);
|
const serverUrl = this.resolveServerUrl(serverId);
|
||||||
|
|
||||||
|
this.authTokenStore.setToken(serverUrl, response.token, response.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveServerUrlFor(serverId?: string): string {
|
||||||
|
return this.resolveServerUrl(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private endpointFor(serverId?: string): string {
|
private endpointFor(serverId?: string): string {
|
||||||
|
|||||||
@@ -101,10 +101,7 @@ export class MessageSigningService {
|
|||||||
const stored = this.readStoredKeyPair();
|
const stored = this.readStoredKeyPair();
|
||||||
|
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const [publicKey, privateKey] = await Promise.all([
|
const [publicKey, privateKey] = await Promise.all([crypto.subtle.importKey('jwk', stored.publicKeyJwk, { name: 'Ed25519' }, true, ['verify']), crypto.subtle.importKey('jwk', stored.privateKeyJwk, { name: 'Ed25519' }, false, ['sign'])]);
|
||||||
crypto.subtle.importKey('jwk', stored.publicKeyJwk, { name: 'Ed25519' }, true, ['verify']),
|
|
||||||
crypto.subtle.importKey('jwk', stored.privateKeyJwk, { name: 'Ed25519' }, false, ['sign'])
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { publicKey, privateKey };
|
return { publicKey, privateKey };
|
||||||
}
|
}
|
||||||
@@ -114,10 +111,7 @@ export class MessageSigningService {
|
|||||||
true,
|
true,
|
||||||
['sign', 'verify']
|
['sign', 'verify']
|
||||||
);
|
);
|
||||||
const [publicKeyJwk, privateKeyJwk] = await Promise.all([
|
const [publicKeyJwk, privateKeyJwk] = await Promise.all([crypto.subtle.exportKey('jwk', generated.publicKey), crypto.subtle.exportKey('jwk', generated.privateKey)]);
|
||||||
crypto.subtle.exportKey('jwk', generated.publicKey),
|
|
||||||
crypto.subtle.exportKey('jwk', generated.privateKey)
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk });
|
this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { ProvisionSecretStoreService } from './provision-secret-store.service';
|
||||||
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
|
||||||
|
describe('ProvisionSecretStoreService', () => {
|
||||||
|
let service: ProvisionSecretStoreService;
|
||||||
|
let electronBridge: ElectronBridgeService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const sessionStorageMap = new Map<string, string>();
|
||||||
|
|
||||||
|
vi.stubGlobal('sessionStorage', {
|
||||||
|
getItem: (key: string) => sessionStorageMap.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { sessionStorageMap.set(key, value); },
|
||||||
|
removeItem: (key: string) => { sessionStorageMap.delete(key); },
|
||||||
|
clear: () => { sessionStorageMap.clear(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
electronBridge = {
|
||||||
|
isAvailable: false,
|
||||||
|
getApi: () => null,
|
||||||
|
requireApi: () => {
|
||||||
|
throw new Error('Electron API is not available in this runtime.');
|
||||||
|
}
|
||||||
|
} as ElectronBridgeService;
|
||||||
|
|
||||||
|
service = new ProvisionSecretStoreService(electronBridge);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and retrieves provision secrets in session storage when electron is unavailable', async () => {
|
||||||
|
await service.storeSecret('home-user-1', 'secret-abc');
|
||||||
|
await expect(service.getSecret('home-user-1')).resolves.toBe('secret-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses electron secure storage when available', async () => {
|
||||||
|
const storeProvisionSecret = vi.fn(async () => true);
|
||||||
|
const getProvisionSecret = vi.fn(async () => 'electron-secret');
|
||||||
|
|
||||||
|
electronBridge = {
|
||||||
|
isAvailable: true,
|
||||||
|
getApi: () => ({
|
||||||
|
storeProvisionSecret,
|
||||||
|
getProvisionSecret
|
||||||
|
}),
|
||||||
|
requireApi: () => ({
|
||||||
|
storeProvisionSecret,
|
||||||
|
getProvisionSecret
|
||||||
|
})
|
||||||
|
} as unknown as ElectronBridgeService;
|
||||||
|
|
||||||
|
service = new ProvisionSecretStoreService(electronBridge);
|
||||||
|
|
||||||
|
await service.storeSecret('home-user-1', 'secret-abc');
|
||||||
|
await expect(service.getSecret('home-user-1')).resolves.toBe('electron-secret');
|
||||||
|
|
||||||
|
expect(storeProvisionSecret).toHaveBeenCalledWith('home-user-1', 'secret-abc');
|
||||||
|
expect(getProvisionSecret).toHaveBeenCalledWith('home-user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
|
||||||
|
const SESSION_STORAGE_PREFIX = 'metoyou.provisionSecret.';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ProvisionSecretStoreService {
|
||||||
|
private readonly electronBridge: ElectronBridgeService;
|
||||||
|
|
||||||
|
constructor(electronBridge: ElectronBridgeService = inject(ElectronBridgeService)) {
|
||||||
|
this.electronBridge = electronBridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeSecret(homeUserId: string, secret: string): Promise<void> {
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (api?.storeProvisionSecret) {
|
||||||
|
await api.storeProvisionSecret(homeUserId, secret);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(this.sessionKey(homeUserId), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSecret(homeUserId: string): Promise<string | null> {
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (api?.getProvisionSecret) {
|
||||||
|
return api.getProvisionSecret(homeUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionStorage.getItem(this.sessionKey(homeUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasSecret(homeUserId: string): Promise<boolean> {
|
||||||
|
const secret = await this.getSecret(homeUserId);
|
||||||
|
|
||||||
|
return !!secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sessionKey(homeUserId: string): string {
|
||||||
|
return `${SESSION_STORAGE_PREFIX}${homeUserId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateProvisionSecret(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import type { User } from '../../../../shared-kernel';
|
||||||
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
|
import type { LoginResponse } from '../../domain/models/authentication.model';
|
||||||
|
import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model';
|
||||||
|
import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules';
|
||||||
|
import { type ResolvedSignalIdentity, resolveSignalIdentity } from '../../domain/logic/signal-server-credential-resolution.rules';
|
||||||
|
import { resolveSelfPresenceUserIds } from '../../domain/logic/self-presence-identity.rules';
|
||||||
|
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||||
|
import { ProvisionSecretStoreService, generateProvisionSecret } from './provision-secret-store.service';
|
||||||
|
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||||
|
import { SignalServerProvisionerService, type ProvisionResult } from './signal-server-provisioner.service';
|
||||||
|
import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service';
|
||||||
|
|
||||||
|
export type EnsureProvisionedResult =
|
||||||
|
| { kind: 'existing'; credential: SignalServerCredential }
|
||||||
|
| { kind: 'provisioned'; result: ProvisionResult }
|
||||||
|
| { kind: 'skipped'; reason: 'no-home-user' | 'no-provision-secret' | 'already-valid' }
|
||||||
|
| { kind: 'collision'; error: ProvisionUsernameCollisionError };
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SignalServerAuthService {
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly credentialStore = inject(SignalServerCredentialStoreService);
|
||||||
|
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||||
|
private readonly provisionSecretStore = inject(ProvisionSecretStoreService);
|
||||||
|
private readonly provisioner = inject(SignalServerProvisionerService);
|
||||||
|
private readonly provisionNotice = inject(SignalServerProvisionNoticeService);
|
||||||
|
private readonly provisionInFlight = new Map<string, Promise<EnsureProvisionedResult>>();
|
||||||
|
|
||||||
|
getCredential(serverUrl: string): SignalServerCredential | null {
|
||||||
|
return this.credentialStore.getCredential(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValidCredential(serverUrl: string): boolean {
|
||||||
|
return this.credentialStore.hasValidCredential(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertCredentialFromLogin(
|
||||||
|
serverUrl: string,
|
||||||
|
response: LoginResponse,
|
||||||
|
options: { provisioned?: boolean } = {}
|
||||||
|
): SignalServerCredential {
|
||||||
|
return this.provisioner.upsertManualCredential(serverUrl, response, options.provisioned ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCredential(serverUrl: string): void {
|
||||||
|
this.credentialStore.clearCredential(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateHomeCredential(user: Pick<User, 'id' | 'username' | 'displayName' | 'homeSignalServerUrl'>): void {
|
||||||
|
const homeSignalServerUrl = user.homeSignalServerUrl?.trim();
|
||||||
|
|
||||||
|
if (!homeSignalServerUrl || this.credentialStore.hasValidCredential(homeSignalServerUrl)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenEntry = this.authTokenStore.getTokenEntry(homeSignalServerUrl);
|
||||||
|
|
||||||
|
if (!tokenEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.credentialStore.upsertCredential({
|
||||||
|
serverUrl: homeSignalServerUrl,
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
token: tokenEntry.token,
|
||||||
|
expiresAt: tokenEntry.expiresAt,
|
||||||
|
provisioned: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureHomeProvisionSecret(homeUser: Pick<User, 'id'>, existingSecret?: string | null): Promise<string> {
|
||||||
|
const stored = existingSecret ?? await this.provisionSecretStore.getSecret(homeUser.id);
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = generateProvisionSecret();
|
||||||
|
|
||||||
|
await this.provisionSecretStore.storeSecret(homeUser.id, generated);
|
||||||
|
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureProvisioned(serverUrl: string, homeUser?: User | null): Promise<EnsureProvisionedResult> {
|
||||||
|
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||||
|
const existing = this.credentialStore.getCredential(normalizedUrl);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { kind: 'existing', credential: existing };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inFlight = this.provisionInFlight.get(normalizedUrl);
|
||||||
|
|
||||||
|
if (inFlight) {
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provisionPromise = this.runProvision(normalizedUrl, homeUser);
|
||||||
|
|
||||||
|
this.provisionInFlight.set(normalizedUrl, provisionPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await provisionPromise;
|
||||||
|
} finally {
|
||||||
|
this.provisionInFlight.delete(normalizedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveActorUserIdForServer(serverUrl: string | undefined, fallbackUserId: string): string {
|
||||||
|
if (!serverUrl?.trim()) {
|
||||||
|
return fallbackUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = this.getCredential(serverUrl);
|
||||||
|
|
||||||
|
return credential?.userId ?? fallbackUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveSelfPresenceUserIdsForRoom(
|
||||||
|
currentUser: Pick<User, 'id' | 'oderId'> | null | undefined,
|
||||||
|
roomSourceUrl: string | undefined
|
||||||
|
): ReadonlySet<string> {
|
||||||
|
const homeOderId = currentUser?.oderId || currentUser?.id;
|
||||||
|
|
||||||
|
return resolveSelfPresenceUserIds({
|
||||||
|
homeUserId: currentUser?.id,
|
||||||
|
homeOderId,
|
||||||
|
actorUserId: homeOderId
|
||||||
|
? this.resolveActorUserIdForServer(roomSourceUrl, homeOderId)
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveCredentialForSignalUrl(
|
||||||
|
signalUrl: string,
|
||||||
|
homeUser?: Pick<User, 'id' | 'homeSignalServerUrl' | 'displayName'> | null
|
||||||
|
): ResolvedSignalIdentity | null {
|
||||||
|
const httpUrl = signalUrl.replace(/^ws/i, 'http');
|
||||||
|
|
||||||
|
return resolveSignalIdentity(
|
||||||
|
this.credentialStore.getCredential(httpUrl),
|
||||||
|
this.authTokenStore.getTokenEntry(httpUrl),
|
||||||
|
homeUser
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runProvision(
|
||||||
|
normalizedUrl: string,
|
||||||
|
homeUser?: User | null
|
||||||
|
): Promise<EnsureProvisionedResult> {
|
||||||
|
const user = homeUser ?? await firstValueFrom(this.store.select(selectCurrentUser));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { kind: 'skipped', reason: 'no-home-user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const provisionSecret = await this.provisionSecretStore.getSecret(user.id);
|
||||||
|
|
||||||
|
if (!provisionSecret) {
|
||||||
|
return { kind: 'skipped', reason: 'no-provision-secret' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.provisioner.provisionOnServer({
|
||||||
|
serverUrl: normalizedUrl,
|
||||||
|
homeUser: user,
|
||||||
|
provisionSecret
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.usedSuffix) {
|
||||||
|
this.provisionNotice.publish({
|
||||||
|
serverName: this.resolveServerDisplayName(normalizedUrl),
|
||||||
|
preferredUsername: user.username,
|
||||||
|
provisionedUsername: result.username
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'provisioned', result };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ProvisionUsernameCollisionError) {
|
||||||
|
return { kind: 'collision', error };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeServerUrl(serverUrl: string): string {
|
||||||
|
return serverUrl.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveServerDisplayName(serverUrl: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(serverUrl).hostname;
|
||||||
|
} catch {
|
||||||
|
return serverUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
|
import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules';
|
||||||
|
import { SignalServerAuthService } from './signal-server-auth.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SignalServerAuthorizeService {
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||||
|
|
||||||
|
async ensureCredentialForServerUrl(serverUrl: string): Promise<boolean> {
|
||||||
|
if (this.signalServerAuth.hasValidCredential(serverUrl)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await firstValueFrom(this.store.select(selectCurrentUser));
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.signalServerAuth.ensureProvisioned(serverUrl, currentUser);
|
||||||
|
|
||||||
|
if (result.kind === 'existing' || result.kind === 'provisioned') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.kind === 'collision') {
|
||||||
|
await this.navigateToAuthorize(serverUrl, this.router.url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.kind === 'skipped' && result.reason === 'no-provision-secret') {
|
||||||
|
await this.navigateToAuthorize(serverUrl, this.router.url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise<void> {
|
||||||
|
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
||||||
|
name: this.buildEndpointName(serverUrl),
|
||||||
|
url: serverUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.router.navigate(['/login'], {
|
||||||
|
queryParams: buildLoginReturnQueryParams(returnUrl, undefined, {
|
||||||
|
mode: AUTH_MODE_AUTHORIZE,
|
||||||
|
serverId: endpoint.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEndpointName(serverUrl: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(serverUrl).hostname;
|
||||||
|
} catch {
|
||||||
|
return serverUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach
|
||||||
|
} from 'vitest';
|
||||||
|
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||||
|
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||||
|
|
||||||
|
describe('SignalServerCredentialStoreService', () => {
|
||||||
|
let service: SignalServerCredentialStoreService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||||
|
removeItem: (key: string) => { storage.delete(key); },
|
||||||
|
clear: () => { storage.clear(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
service = new SignalServerCredentialStoreService(new AuthTokenStoreService());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and retrieves credentials by normalized server url', () => {
|
||||||
|
service.upsertCredential({
|
||||||
|
serverUrl: 'https://signal.example.com/',
|
||||||
|
userId: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
token: 'token-abc',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
provisioned: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const credential = service.getCredential('https://signal.example.com');
|
||||||
|
|
||||||
|
expect(credential?.userId).toBe('user-1');
|
||||||
|
expect(credential?.token).toBe('token-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears expired credentials on read', () => {
|
||||||
|
service.upsertCredential({
|
||||||
|
serverUrl: 'https://signal.example.com',
|
||||||
|
userId: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
token: 'expired',
|
||||||
|
expiresAt: Date.now() - 1,
|
||||||
|
provisioned: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.getCredential('https://signal.example.com')).toBeNull();
|
||||||
|
expect(service.hasValidCredential('https://signal.example.com')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes credentials for a single server url', () => {
|
||||||
|
service.upsertCredential({
|
||||||
|
serverUrl: 'https://signal-a.example.com',
|
||||||
|
userId: 'user-a',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
token: 'token-a',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
provisioned: true
|
||||||
|
});
|
||||||
|
|
||||||
|
service.upsertCredential({
|
||||||
|
serverUrl: 'https://signal-b.example.com',
|
||||||
|
userId: 'user-b',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
token: 'token-b',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
provisioned: true
|
||||||
|
});
|
||||||
|
|
||||||
|
service.clearCredential('https://signal-a.example.com');
|
||||||
|
|
||||||
|
expect(service.getCredential('https://signal-a.example.com')).toBeNull();
|
||||||
|
expect(service.getCredential('https://signal-b.example.com')?.token).toBe('token-b');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model';
|
||||||
|
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'metoyou.signalServerCredentials';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SignalServerCredentialStoreService {
|
||||||
|
private readonly authTokenStore: AuthTokenStoreService;
|
||||||
|
|
||||||
|
constructor(authTokenStore: AuthTokenStoreService = inject(AuthTokenStoreService)) {
|
||||||
|
this.authTokenStore = authTokenStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertCredential(credential: SignalServerCredential): void {
|
||||||
|
const normalizedUrl = this.normalizeServerUrl(credential.serverUrl);
|
||||||
|
const store = this.readStore();
|
||||||
|
|
||||||
|
store[normalizedUrl] = {
|
||||||
|
...credential,
|
||||||
|
serverUrl: normalizedUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
this.writeStore(store);
|
||||||
|
this.authTokenStore.setToken(normalizedUrl, credential.token, credential.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCredential(serverUrl: string): SignalServerCredential | null {
|
||||||
|
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||||
|
const entry = this.readStore()[normalizedUrl];
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
this.clearCredential(serverUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValidCredential(serverUrl: string): boolean {
|
||||||
|
return this.getCredential(serverUrl) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCredential(serverUrl: string): void {
|
||||||
|
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||||
|
const store = this.readStore();
|
||||||
|
const nextStore = Object.fromEntries(
|
||||||
|
Object.entries(store).filter(([key]) => key !== normalizedUrl)
|
||||||
|
) as Record<string, SignalServerCredential>;
|
||||||
|
|
||||||
|
this.writeStore(nextStore);
|
||||||
|
this.authTokenStore.clearToken(normalizedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
listValidCredentials(): SignalServerCredential[] {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
return Object.values(this.readStore()).filter((entry) => entry.expiresAt > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStore(): Record<string, SignalServerCredential> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, SignalServerCredential>;
|
||||||
|
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeStore(store: Record<string, SignalServerCredential>): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeServerUrl(serverUrl: string): string {
|
||||||
|
return serverUrl.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export interface SignalServerProvisionNotice {
|
||||||
|
serverName: string;
|
||||||
|
preferredUsername: string;
|
||||||
|
provisionedUsername: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SignalServerProvisionNoticeService {
|
||||||
|
readonly notice = signal<SignalServerProvisionNotice | null>(null);
|
||||||
|
|
||||||
|
publish(notice: SignalServerProvisionNotice): void {
|
||||||
|
this.notice.set(notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.notice.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { SignalServerProvisionerService } from './signal-server-provisioner.service';
|
||||||
|
import { AuthTokenStoreService } from './auth-token-store.service';
|
||||||
|
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||||
|
import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules';
|
||||||
|
import type { User } from '../../../../shared-kernel';
|
||||||
|
|
||||||
|
describe('SignalServerProvisionerService', () => {
|
||||||
|
let service: SignalServerProvisionerService;
|
||||||
|
let httpPost: ReturnType<typeof vi.fn>;
|
||||||
|
let credentialStore: SignalServerCredentialStoreService;
|
||||||
|
|
||||||
|
const homeUser: User = {
|
||||||
|
id: 'a3f2b1c4-5678-90ab-cdef-1234567890ab',
|
||||||
|
oderId: 'a3f2b1c4-5678-90ab-cdef-1234567890ab',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
status: 'online',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
homeSignalServerUrl: 'https://home.example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||||
|
removeItem: (key: string) => { storage.delete(key); },
|
||||||
|
clear: () => { storage.clear(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
httpPost = vi.fn();
|
||||||
|
credentialStore = new SignalServerCredentialStoreService(new AuthTokenStoreService());
|
||||||
|
service = new SignalServerProvisionerService(
|
||||||
|
{ post: httpPost } as unknown as HttpClient,
|
||||||
|
credentialStore
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers on a foreign server when the preferred username is available', async () => {
|
||||||
|
httpPost.mockReturnValue(of({
|
||||||
|
id: 'foreign-user-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
token: 'foreign-token',
|
||||||
|
expiresAt: Date.now() + 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await service.provisionOnServer({
|
||||||
|
serverUrl: 'https://foreign.example.com',
|
||||||
|
homeUser,
|
||||||
|
provisionSecret: 'provision-secret'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.username).toBe('alice');
|
||||||
|
expect(result.usedSuffix).toBe(false);
|
||||||
|
expect(credentialStore.getCredential('https://foreign.example.com')?.userId).toBe('foreign-user-1');
|
||||||
|
expect(httpPost).toHaveBeenCalledWith(
|
||||||
|
'https://foreign.example.com/api/users/register',
|
||||||
|
{
|
||||||
|
username: 'alice',
|
||||||
|
password: 'provision-secret',
|
||||||
|
displayName: 'Alice'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs in when the preferred username was provisioned earlier', async () => {
|
||||||
|
httpPost
|
||||||
|
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
||||||
|
.mockReturnValueOnce(of({
|
||||||
|
id: 'foreign-user-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
token: 'foreign-token',
|
||||||
|
expiresAt: Date.now() + 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await service.provisionOnServer({
|
||||||
|
serverUrl: 'https://foreign.example.com',
|
||||||
|
homeUser,
|
||||||
|
provisionSecret: 'provision-secret'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.username).toBe('alice');
|
||||||
|
expect(httpPost).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'https://foreign.example.com/api/users/login',
|
||||||
|
{
|
||||||
|
username: 'alice',
|
||||||
|
password: 'provision-secret'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers with a suffixed username when the preferred name belongs to someone else', async () => {
|
||||||
|
httpPost
|
||||||
|
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
||||||
|
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
|
||||||
|
.mockReturnValueOnce(of({
|
||||||
|
id: 'foreign-user-2',
|
||||||
|
username: 'alice-a3f2b1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
token: 'foreign-token-2',
|
||||||
|
expiresAt: Date.now() + 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await service.provisionOnServer({
|
||||||
|
serverUrl: 'https://foreign.example.com',
|
||||||
|
homeUser,
|
||||||
|
provisionSecret: 'provision-secret'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.username).toBe('alice-a3f2b1');
|
||||||
|
expect(result.usedSuffix).toBe(true);
|
||||||
|
expect(httpPost).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
'https://foreign.example.com/api/users/register',
|
||||||
|
{
|
||||||
|
username: 'alice-a3f2b1',
|
||||||
|
password: 'provision-secret',
|
||||||
|
displayName: 'Alice'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when all username candidates are exhausted', async () => {
|
||||||
|
httpPost
|
||||||
|
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
||||||
|
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
|
||||||
|
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
||||||
|
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })));
|
||||||
|
|
||||||
|
await expect(service.provisionOnServer({
|
||||||
|
serverUrl: 'https://foreign.example.com',
|
||||||
|
homeUser,
|
||||||
|
provisionSecret: 'provision-secret'
|
||||||
|
})).rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an existing credential without making network calls', async () => {
|
||||||
|
credentialStore.upsertCredential({
|
||||||
|
serverUrl: 'https://foreign.example.com',
|
||||||
|
userId: 'foreign-user-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
token: 'foreign-token',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
provisioned: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.provisionOnServer({
|
||||||
|
serverUrl: 'https://foreign.example.com',
|
||||||
|
homeUser,
|
||||||
|
provisionSecret: 'provision-secret'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.username).toBe('alice');
|
||||||
|
expect(httpPost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import type { User } from '../../../../shared-kernel';
|
||||||
|
import type { LoginResponse } from '../../domain/models/authentication.model';
|
||||||
|
import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model';
|
||||||
|
import { ProvisionUsernameCollisionError, buildProvisionUsernameCandidates } from '../../domain/logic/signal-server-provision.rules';
|
||||||
|
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||||
|
|
||||||
|
export interface ProvisionResult {
|
||||||
|
credential: SignalServerCredential;
|
||||||
|
username: string;
|
||||||
|
usedSuffix: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SignalServerProvisionerService {
|
||||||
|
private readonly http: HttpClient;
|
||||||
|
private readonly credentialStore: SignalServerCredentialStoreService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
http: HttpClient = inject(HttpClient),
|
||||||
|
credentialStore: SignalServerCredentialStoreService = inject(SignalServerCredentialStoreService)
|
||||||
|
) {
|
||||||
|
this.http = http;
|
||||||
|
this.credentialStore = credentialStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
async provisionOnServer(params: {
|
||||||
|
serverUrl: string;
|
||||||
|
homeUser: Pick<User, 'id' | 'username' | 'displayName'>;
|
||||||
|
provisionSecret: string;
|
||||||
|
}): Promise<ProvisionResult> {
|
||||||
|
const normalizedUrl = this.normalizeServerUrl(params.serverUrl);
|
||||||
|
const existing = this.credentialStore.getCredential(normalizedUrl);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
credential: existing,
|
||||||
|
username: existing.username,
|
||||||
|
usedSuffix: existing.username !== params.homeUser.username.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = buildProvisionUsernameCandidates(params.homeUser.username, params.homeUser.id);
|
||||||
|
|
||||||
|
for (let index = 0; index < candidates.length; index += 1) {
|
||||||
|
const candidate = candidates[index];
|
||||||
|
const usedSuffix = index > 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.register(normalizedUrl, candidate, params.provisionSecret, params.homeUser.displayName);
|
||||||
|
|
||||||
|
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isHttpStatus(error, 409)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.login(normalizedUrl, candidate, params.provisionSecret);
|
||||||
|
|
||||||
|
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
|
||||||
|
} catch (loginError) {
|
||||||
|
if (!this.isHttpStatus(loginError, 401)) {
|
||||||
|
throw loginError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProvisionUsernameCollisionError(normalizedUrl, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertManualCredential(
|
||||||
|
serverUrl: string,
|
||||||
|
response: LoginResponse,
|
||||||
|
provisioned = false
|
||||||
|
): SignalServerCredential {
|
||||||
|
const credential: SignalServerCredential = {
|
||||||
|
serverUrl: this.normalizeServerUrl(serverUrl),
|
||||||
|
userId: response.id,
|
||||||
|
username: response.username,
|
||||||
|
displayName: response.displayName,
|
||||||
|
token: response.token,
|
||||||
|
expiresAt: response.expiresAt,
|
||||||
|
provisioned
|
||||||
|
};
|
||||||
|
|
||||||
|
this.credentialStore.upsertCredential(credential);
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async register(
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
displayName: string
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.post<LoginResponse>(`${serverUrl}/api/users/register`, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
displayName
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.post<LoginResponse>(`${serverUrl}/api/users/login`, {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistProvisionResult(
|
||||||
|
serverUrl: string,
|
||||||
|
response: LoginResponse,
|
||||||
|
usedSuffix: boolean
|
||||||
|
): ProvisionResult {
|
||||||
|
const credential = this.upsertManualCredential(serverUrl, response, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
credential,
|
||||||
|
username: response.username,
|
||||||
|
usedSuffix
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isHttpStatus(error: unknown, status: number): boolean {
|
||||||
|
return error instanceof HttpErrorResponse && error.status === status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeServerUrl(serverUrl: string): string {
|
||||||
|
return serverUrl.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,54 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { waitForAuthenticationOutcome } from './auth-navigation.rules';
|
import {
|
||||||
|
buildLoginReturnQueryParams,
|
||||||
|
resolveSafeReturnUrl,
|
||||||
|
waitForAuthenticationOutcome
|
||||||
|
} from './auth-navigation.rules';
|
||||||
|
|
||||||
|
describe('resolveSafeReturnUrl', () => {
|
||||||
|
it('returns the requested in-app path unchanged', () => {
|
||||||
|
expect(resolveSafeReturnUrl('/servers')).toBe('/servers');
|
||||||
|
expect(resolveSafeReturnUrl('/room/abc')).toBe('/room/abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps nested login returnUrl chains to the original destination', () => {
|
||||||
|
const nested = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
|
||||||
|
|
||||||
|
expect(resolveSafeReturnUrl(nested)).toBe('/servers');
|
||||||
|
expect(resolveSafeReturnUrl(`/login?returnUrl=${encodeURIComponent(nested)}`)).toBe('/servers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to dashboard for auth-only return targets', () => {
|
||||||
|
expect(resolveSafeReturnUrl('/login')).toBe('/dashboard');
|
||||||
|
expect(resolveSafeReturnUrl('/register')).toBe('/dashboard');
|
||||||
|
expect(resolveSafeReturnUrl(null)).toBe('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects open redirects and protocol-relative paths', () => {
|
||||||
|
expect(resolveSafeReturnUrl('//evil.example/phish')).toBe('/dashboard');
|
||||||
|
expect(resolveSafeReturnUrl('https://evil.example/phish')).toBe('/dashboard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildLoginReturnQueryParams', () => {
|
||||||
|
it('preserves a safe destination when redirecting from protected routes', () => {
|
||||||
|
expect(buildLoginReturnQueryParams('/servers')).toEqual({ returnUrl: '/servers' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not nest login returnUrl values', () => {
|
||||||
|
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Fservers')).toEqual({ returnUrl: '/servers' });
|
||||||
|
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers')).toEqual({
|
||||||
|
returnUrl: '/servers'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits returnUrl when there is no meaningful destination', () => {
|
||||||
|
expect(buildLoginReturnQueryParams('/login')).toEqual({});
|
||||||
|
expect(buildLoginReturnQueryParams('/register')).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('waitForAuthenticationOutcome', () => {
|
describe('waitForAuthenticationOutcome', () => {
|
||||||
it('resolves when authentication storage preparation succeeds', async () => {
|
it('resolves when authentication storage preparation succeeds', async () => {
|
||||||
|
|||||||
@@ -8,10 +8,101 @@ import {
|
|||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import type { User } from '../../../../shared-kernel';
|
import type { User } from '../../../../shared-kernel';
|
||||||
|
|
||||||
|
export const DEFAULT_POST_AUTH_URL = '/dashboard';
|
||||||
|
export const AUTH_MODE_AUTHORIZE = 'authorize';
|
||||||
|
|
||||||
|
const AUTH_ROUTE_PATHS = new Set(['/login', '/register']);
|
||||||
|
const MAX_RETURN_URL_DEPTH = 10;
|
||||||
|
|
||||||
export type AuthenticationOutcome =
|
export type AuthenticationOutcome =
|
||||||
| { kind: 'success'; user: User }
|
| { kind: 'success'; user: User }
|
||||||
| { kind: 'failure'; error: string };
|
| { kind: 'failure'; error: string };
|
||||||
|
|
||||||
|
export function isAuthRoutePath(path: string): boolean {
|
||||||
|
return AUTH_ROUTE_PATHS.has(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoutePathFromUrl(url: string): string {
|
||||||
|
if (!url) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [path] = url.split(/[?#]/, 1);
|
||||||
|
|
||||||
|
return path || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractReturnUrlParam(url: string): string | null {
|
||||||
|
const queryStart = url.indexOf('?');
|
||||||
|
|
||||||
|
if (queryStart === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashStart = url.indexOf('#', queryStart + 1);
|
||||||
|
const query = hashStart === -1
|
||||||
|
? url.slice(queryStart + 1)
|
||||||
|
: url.slice(queryStart + 1, hashStart);
|
||||||
|
|
||||||
|
return new URLSearchParams(query).get('returnUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSafeReturnUrl(
|
||||||
|
url: string | null | undefined,
|
||||||
|
fallback = DEFAULT_POST_AUTH_URL
|
||||||
|
): string {
|
||||||
|
let candidate = url?.trim() ?? '';
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
while (candidate && depth < MAX_RETURN_URL_DEPTH) {
|
||||||
|
if (!candidate.startsWith('/') || candidate.startsWith('//')) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = getRoutePathFromUrl(candidate);
|
||||||
|
|
||||||
|
if (!isAuthRoutePath(path)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedReturnUrl = extractReturnUrlParam(candidate)?.trim();
|
||||||
|
|
||||||
|
if (!nestedReturnUrl) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate = nestedReturnUrl;
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLoginReturnQueryParams(
|
||||||
|
currentUrl: string,
|
||||||
|
fallback = DEFAULT_POST_AUTH_URL,
|
||||||
|
extra: Record<string, string | undefined> = {}
|
||||||
|
): Record<string, string> {
|
||||||
|
const safeReturnUrl = resolveSafeReturnUrl(currentUrl, fallback);
|
||||||
|
const queryParams: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (safeReturnUrl !== fallback) {
|
||||||
|
queryParams['returnUrl'] = safeReturnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(extra)) {
|
||||||
|
if (value?.trim()) {
|
||||||
|
queryParams[key] = value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthorizeAuthMode(mode: string | null | undefined): boolean {
|
||||||
|
return mode?.trim() === AUTH_MODE_AUTHORIZE;
|
||||||
|
}
|
||||||
|
|
||||||
export function waitForAuthenticationOutcome(
|
export function waitForAuthenticationOutcome(
|
||||||
actions$: Observable<{ type: string; user?: User; error?: string }>
|
actions$: Observable<{ type: string; user?: User; error?: string }>
|
||||||
): Observable<AuthenticationOutcome> {
|
): Observable<AuthenticationOutcome> {
|
||||||
|
|||||||
@@ -16,13 +16,9 @@ describe('auth-session.rules', () => {
|
|||||||
} as Pick<User, 'homeSignalServerUrl'>;
|
} as Pick<User, 'homeSignalServerUrl'>;
|
||||||
|
|
||||||
it('collects home and active server urls without duplicates', () => {
|
it('collects home and active server urls without duplicates', () => {
|
||||||
expect(collectSessionTokenLookupUrls(user, 'https://signal.example.com')).toEqual([
|
expect(collectSessionTokenLookupUrls(user, 'https://signal.example.com')).toEqual(['https://signal.example.com']);
|
||||||
'https://signal.example.com'
|
|
||||||
]);
|
expect(collectSessionTokenLookupUrls(user, 'http://localhost:3001')).toEqual(['http://localhost:3001', 'https://signal.example.com']);
|
||||||
expect(collectSessionTokenLookupUrls(user, 'http://localhost:3001')).toEqual([
|
|
||||||
'http://localhost:3001',
|
|
||||||
'https://signal.example.com'
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires a valid token for a known server url', () => {
|
it('requires a valid token for a known server url', () => {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { isSelfPresenceUserId, resolveSelfPresenceUserIds } from './self-presence-identity.rules';
|
||||||
|
|
||||||
|
describe('resolveSelfPresenceUserIds', () => {
|
||||||
|
it('includes home user id and oderId', () => {
|
||||||
|
const ids = resolveSelfPresenceUserIds({
|
||||||
|
homeUserId: 'home-id',
|
||||||
|
homeOderId: 'peer-a'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...ids]).toEqual(['home-id', 'peer-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the per-server actor user id when provisioned on a foreign server', () => {
|
||||||
|
const ids = resolveSelfPresenceUserIds({
|
||||||
|
homeUserId: 'home-id',
|
||||||
|
homeOderId: 'peer-a',
|
||||||
|
actorUserId: 'foreign-id'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...ids]).toEqual([
|
||||||
|
'home-id',
|
||||||
|
'peer-a',
|
||||||
|
'foreign-id'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates when actor id matches home id', () => {
|
||||||
|
const ids = resolveSelfPresenceUserIds({
|
||||||
|
homeUserId: 'same-id',
|
||||||
|
homeOderId: 'same-id',
|
||||||
|
actorUserId: 'same-id'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...ids]).toEqual(['same-id']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSelfPresenceUserId', () => {
|
||||||
|
it('returns true when the user id is part of the self set', () => {
|
||||||
|
const selfIds = resolveSelfPresenceUserIds({
|
||||||
|
homeUserId: 'home-id',
|
||||||
|
actorUserId: 'foreign-id'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isSelfPresenceUserId('foreign-id', selfIds)).toBe(true);
|
||||||
|
expect(isSelfPresenceUserId('other-id', selfIds)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export interface SelfPresenceIdentityInput {
|
||||||
|
homeUserId?: string;
|
||||||
|
homeOderId?: string;
|
||||||
|
actorUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect every user id that represents the local user on a room's signal server. */
|
||||||
|
export function resolveSelfPresenceUserIds(input: SelfPresenceIdentityInput): ReadonlySet<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
if (input.homeUserId?.trim()) {
|
||||||
|
ids.add(input.homeUserId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.homeOderId?.trim()) {
|
||||||
|
ids.add(input.homeOderId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.actorUserId?.trim()) {
|
||||||
|
ids.add(input.actorUserId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelfPresenceUserId(
|
||||||
|
userId: string | undefined,
|
||||||
|
selfIds: ReadonlySet<string>
|
||||||
|
): boolean {
|
||||||
|
return !!userId?.trim() && selfIds.has(userId.trim());
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import { resolveSignalIdentity } from './signal-server-credential-resolution.rules';
|
||||||
|
|
||||||
|
describe('resolveSignalIdentity', () => {
|
||||||
|
const homeUser = {
|
||||||
|
id: 'home-user-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
homeSignalServerUrl: 'https://signal.example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('prefers the per-signal credential when present', () => {
|
||||||
|
const resolved = resolveSignalIdentity(
|
||||||
|
{ userId: 'provisioned-1', token: 'cred-token', displayName: 'Alice On Foreign' },
|
||||||
|
{ token: 'legacy-token' },
|
||||||
|
homeUser
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
userId: 'provisioned-1',
|
||||||
|
token: 'cred-token',
|
||||||
|
displayName: 'Alice On Foreign',
|
||||||
|
homeSignalServerUrl: 'https://signal.example.com'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the legacy session token using the home identity when no credential exists', () => {
|
||||||
|
const resolved = resolveSignalIdentity(
|
||||||
|
null,
|
||||||
|
{ token: 'legacy-token' },
|
||||||
|
homeUser
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
userId: 'home-user-1',
|
||||||
|
token: 'legacy-token',
|
||||||
|
displayName: 'Alice',
|
||||||
|
homeSignalServerUrl: 'https://signal.example.com'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when neither a credential nor a legacy token is available', () => {
|
||||||
|
expect(resolveSignalIdentity(null, null, homeUser)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall back to the legacy token without a known home user id', () => {
|
||||||
|
expect(resolveSignalIdentity(null, { token: 'legacy-token' }, null)).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveSignalIdentity(null, { token: 'legacy-token' }, { displayName: 'Alice' })
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export interface ResolvableHomeUser {
|
||||||
|
id?: string;
|
||||||
|
displayName?: string;
|
||||||
|
homeSignalServerUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSignalIdentity {
|
||||||
|
userId: string;
|
||||||
|
token: string;
|
||||||
|
displayName: string;
|
||||||
|
homeSignalServerUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the identity (oder id + session token) used to `identify` on a signal
|
||||||
|
* server.
|
||||||
|
*
|
||||||
|
* Order of precedence:
|
||||||
|
* 1. The per-signal-server credential (the authoritative source for both home
|
||||||
|
* and provisioned foreign servers).
|
||||||
|
* 2. The legacy single-session token store, reconstructed with the home user's
|
||||||
|
* identity. This keeps `identify` working for sessions restored from disk
|
||||||
|
* that pre-date the per-signal credential store (otherwise the client never
|
||||||
|
* authenticates and the user appears alone in every room).
|
||||||
|
*
|
||||||
|
* Foreign servers are never reconstructed from the legacy token: their account
|
||||||
|
* id is the provisioned id, which only the per-signal credential carries.
|
||||||
|
*/
|
||||||
|
export function resolveSignalIdentity(
|
||||||
|
credential: { userId: string; token: string; displayName: string } | null,
|
||||||
|
legacyToken: { token: string } | null,
|
||||||
|
homeUser: ResolvableHomeUser | null | undefined
|
||||||
|
): ResolvedSignalIdentity | null {
|
||||||
|
if (credential) {
|
||||||
|
return {
|
||||||
|
userId: credential.userId,
|
||||||
|
token: credential.token,
|
||||||
|
displayName: credential.displayName,
|
||||||
|
homeSignalServerUrl: homeUser?.homeSignalServerUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyToken && homeUser?.id) {
|
||||||
|
return {
|
||||||
|
userId: homeUser.id,
|
||||||
|
token: legacyToken.token,
|
||||||
|
displayName: homeUser.displayName ?? '',
|
||||||
|
homeSignalServerUrl: homeUser.homeSignalServerUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
ProvisionUsernameCollisionError,
|
||||||
|
buildProvisionUsernameCandidates,
|
||||||
|
shortHomeUserId
|
||||||
|
} from './signal-server-provision.rules';
|
||||||
|
|
||||||
|
describe('signal-server-provision.rules', () => {
|
||||||
|
it('derives a stable short id from a home user uuid', () => {
|
||||||
|
expect(shortHomeUserId('a3f2b1c4-5678-90ab-cdef-1234567890ab')).toBe('a3f2b1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders username candidates with preferred first then suffixed fallback', () => {
|
||||||
|
expect(
|
||||||
|
buildProvisionUsernameCandidates('alice', 'a3f2b1c4-5678-90ab-cdef-1234567890ab')
|
||||||
|
).toEqual(['alice', 'alice-a3f2b1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates candidates when suffix would repeat preferred username', () => {
|
||||||
|
expect(
|
||||||
|
buildProvisionUsernameCandidates('alice-a3f2b1', 'a3f2b1c4-5678-90ab-cdef-1234567890ab')
|
||||||
|
).toEqual(['alice-a3f2b1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes attempted usernames on collision errors', () => {
|
||||||
|
const error = new ProvisionUsernameCollisionError('https://signal.example.com', ['alice', 'alice-a3f2b1']);
|
||||||
|
|
||||||
|
expect(error.name).toBe('ProvisionUsernameCollisionError');
|
||||||
|
expect(error.serverUrl).toBe('https://signal.example.com');
|
||||||
|
expect(error.attemptedUsernames).toEqual(['alice', 'alice-a3f2b1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export class ProvisionUsernameCollisionError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly serverUrl: string,
|
||||||
|
readonly attemptedUsernames: readonly string[]
|
||||||
|
) {
|
||||||
|
super(`Could not provision account on ${serverUrl}`);
|
||||||
|
this.name = 'ProvisionUsernameCollisionError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortHomeUserId(homeUserId: string): string {
|
||||||
|
return homeUserId.replace(/-/g, '').slice(0, 6)
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProvisionUsernameCandidates(
|
||||||
|
preferredUsername: string,
|
||||||
|
homeUserId: string
|
||||||
|
): string[] {
|
||||||
|
const trimmed = preferredUsername.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [trimmed];
|
||||||
|
const suffix = shortHomeUserId(homeUserId);
|
||||||
|
|
||||||
|
if (suffix && !trimmed.endsWith(`-${suffix}`)) {
|
||||||
|
candidates.push(`${trimmed}-${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(candidates)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface SignalServerCredential {
|
||||||
|
serverUrl: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
provisioned: boolean;
|
||||||
|
}
|
||||||
@@ -5,10 +5,23 @@
|
|||||||
name="lucideLogIn"
|
name="lucideLogIn"
|
||||||
class="w-5 h-5 text-primary"
|
class="w-5 h-5 text-primary"
|
||||||
/>
|
/>
|
||||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.login.title' | translate }}</h1>
|
<h1 class="text-lg font-semibold text-foreground">
|
||||||
|
@if (isAuthorizeMode()) {
|
||||||
|
{{ 'auth.authorize.title' | translate: { serverName: authorizeServerName() } }}
|
||||||
|
} @else {
|
||||||
|
{{ 'auth.login.title' | translate }}
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
@if (isAuthorizeMode()) {
|
||||||
|
<p class="text-xs text-muted-foreground mb-4">{{ 'auth.authorize.description' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-3"
|
||||||
|
(ngSubmit)="submit()"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="login-username"
|
for="login-username"
|
||||||
@@ -19,6 +32,9 @@
|
|||||||
[(ngModel)]="username"
|
[(ngModel)]="username"
|
||||||
type="text"
|
type="text"
|
||||||
id="login-username"
|
id="login-username"
|
||||||
|
name="username"
|
||||||
|
appAutoFocus
|
||||||
|
appSelectOnFocus
|
||||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,6 +48,7 @@
|
|||||||
[(ngModel)]="password"
|
[(ngModel)]="password"
|
||||||
type="password"
|
type="password"
|
||||||
id="login-password"
|
id="login-password"
|
||||||
|
name="password"
|
||||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,6 +61,7 @@
|
|||||||
<select
|
<select
|
||||||
[(ngModel)]="serverId"
|
[(ngModel)]="serverId"
|
||||||
id="login-server"
|
id="login-server"
|
||||||
|
name="serverId"
|
||||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
>
|
>
|
||||||
@for (s of servers(); track s.id) {
|
@for (s of servers(); track s.id) {
|
||||||
@@ -55,12 +73,12 @@
|
|||||||
<p class="text-xs text-destructive">{{ error() }}</p>
|
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
(click)="submit()"
|
type="submit"
|
||||||
type="button"
|
|
||||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{{ 'auth.login.submit' | translate }}
|
{{ 'auth.login.submit' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||||
{{ 'auth.login.noAccount' | translate }}
|
{{ 'auth.login.noAccount' | translate }}
|
||||||
<button
|
<button
|
||||||
@@ -73,4 +91,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
|
OnInit,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
@@ -11,14 +13,26 @@ import { Actions } from '@ngrx/effects';
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideLogIn } from '@ng-icons/lucide';
|
import { lucideLogIn } from '@ng-icons/lucide';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import {
|
||||||
|
filter,
|
||||||
|
firstValueFrom,
|
||||||
|
take
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
import {
|
||||||
|
AUTH_MODE_AUTHORIZE,
|
||||||
|
buildLoginReturnQueryParams,
|
||||||
|
isAuthorizeAuthMode,
|
||||||
|
resolveSafeReturnUrl,
|
||||||
|
waitForAuthenticationOutcome
|
||||||
|
} from '../../domain/logic/auth-navigation.rules';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||||
|
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||||
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -27,6 +41,8 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
|
AutoFocusDirective,
|
||||||
|
SelectOnFocusDirective,
|
||||||
...APP_TRANSLATE_IMPORTS
|
...APP_TRANSLATE_IMPORTS
|
||||||
],
|
],
|
||||||
viewProviders: [provideIcons({ lucideLogIn })],
|
viewProviders: [provideIcons({ lucideLogIn })],
|
||||||
@@ -35,7 +51,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
|||||||
/**
|
/**
|
||||||
* Login form allowing existing users to authenticate against a selected server.
|
* Login form allowing existing users to authenticate against a selected server.
|
||||||
*/
|
*/
|
||||||
export class LoginComponent {
|
export class LoginComponent implements OnInit {
|
||||||
serversSvc = inject(ServerDirectoryFacade);
|
serversSvc = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
servers = this.serversSvc.servers;
|
servers = this.serversSvc.servers;
|
||||||
@@ -43,6 +59,13 @@ export class LoginComponent {
|
|||||||
password = '';
|
password = '';
|
||||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
readonly isAuthorizeMode = signal(false);
|
||||||
|
readonly authorizeServerName = computed(() => {
|
||||||
|
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||||
|
const endpoint = this.servers().find((server) => server.id === sid);
|
||||||
|
|
||||||
|
return endpoint?.name ?? this.appI18n.instant('auth.authorize.defaultServerName');
|
||||||
|
});
|
||||||
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
private readonly appI18n = inject(AppI18nService);
|
||||||
private auth = inject(AuthenticationService);
|
private auth = inject(AuthenticationService);
|
||||||
@@ -54,6 +77,31 @@ export class LoginComponent {
|
|||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const mode = this.route.snapshot.queryParamMap.get('mode');
|
||||||
|
const requestedServerId = this.route.snapshot.queryParamMap.get('serverId')?.trim();
|
||||||
|
|
||||||
|
this.isAuthorizeMode.set(isAuthorizeAuthMode(mode));
|
||||||
|
|
||||||
|
if (requestedServerId) {
|
||||||
|
this.serverId = requestedServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isAuthorizeMode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.select(selectCurrentUser).pipe(
|
||||||
|
filter(Boolean),
|
||||||
|
take(1)
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||||
|
|
||||||
|
void this.router.navigateByUrl(returnUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Validate and submit the login form, then navigate to search on success. */
|
/** Validate and submit the login form, then navigate to search on success. */
|
||||||
submit() {
|
submit() {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
@@ -63,8 +111,24 @@ export class LoginComponent {
|
|||||||
password: this.password,
|
password: this.password,
|
||||||
serverId: sid }).subscribe({
|
serverId: sid }).subscribe({
|
||||||
next: async (resp) => {
|
next: async (resp) => {
|
||||||
if (sid)
|
const serverUrl = this.auth.resolveServerUrlFor(sid);
|
||||||
|
|
||||||
|
if (this.isAuthorizeMode()) {
|
||||||
|
this.store.dispatch(UsersActions.authorizeSignalServer({
|
||||||
|
serverUrl,
|
||||||
|
response: resp,
|
||||||
|
provisioned: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||||
|
|
||||||
|
await this.router.navigateByUrl(returnUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sid) {
|
||||||
this.serversSvc.setActiveServer(sid);
|
this.serversSvc.setActiveServer(sid);
|
||||||
|
}
|
||||||
|
|
||||||
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
|
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
|
||||||
?? this.serversSvc.activeServer()?.url;
|
?? this.serversSvc.activeServer()?.url;
|
||||||
@@ -79,7 +143,7 @@ export class LoginComponent {
|
|||||||
homeSignalServerUrl
|
homeSignalServerUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
this.store.dispatch(UsersActions.authenticateUser({ user, loginResponse: resp }));
|
||||||
|
|
||||||
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
|
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
|
||||||
|
|
||||||
@@ -88,14 +152,9 @@ export class LoginComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||||
|
|
||||||
if (returnUrl?.startsWith('/')) {
|
|
||||||
await this.router.navigateByUrl(returnUrl);
|
await this.router.navigateByUrl(returnUrl);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.router.navigate(['/dashboard']);
|
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.error.set(err?.error?.error || this.appI18n.instant('auth.login.failed'));
|
this.error.set(err?.error?.error || this.appI18n.instant('auth.login.failed'));
|
||||||
@@ -105,10 +164,11 @@ export class LoginComponent {
|
|||||||
|
|
||||||
/** Navigate to the registration page. */
|
/** Navigate to the registration page. */
|
||||||
goRegister() {
|
goRegister() {
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
|
||||||
|
|
||||||
this.router.navigate(['/register'], {
|
this.router.navigate(['/register'], {
|
||||||
queryParams: returnUrl ? { returnUrl } : undefined
|
queryParams: buildLoginReturnQueryParams(this.router.url, undefined, {
|
||||||
|
mode: this.isAuthorizeMode() ? AUTH_MODE_AUTHORIZE : undefined,
|
||||||
|
serverId: this.serverId
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,23 @@
|
|||||||
name="lucideUserPlus"
|
name="lucideUserPlus"
|
||||||
class="w-5 h-5 text-primary"
|
class="w-5 h-5 text-primary"
|
||||||
/>
|
/>
|
||||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.register.title' | translate }}</h1>
|
<h1 class="text-lg font-semibold text-foreground">
|
||||||
|
@if (isAuthorizeMode()) {
|
||||||
|
{{ 'auth.authorize.registerTitle' | translate: { serverName: authorizeServerName() } }}
|
||||||
|
} @else {
|
||||||
|
{{ 'auth.register.title' | translate }}
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
@if (isAuthorizeMode()) {
|
||||||
|
<p class="text-xs text-muted-foreground mb-4">{{ 'auth.authorize.description' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-3"
|
||||||
|
(ngSubmit)="submit()"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="register-username"
|
for="register-username"
|
||||||
@@ -19,6 +32,9 @@
|
|||||||
[(ngModel)]="username"
|
[(ngModel)]="username"
|
||||||
type="text"
|
type="text"
|
||||||
id="register-username"
|
id="register-username"
|
||||||
|
name="username"
|
||||||
|
appAutoFocus
|
||||||
|
appSelectOnFocus
|
||||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,6 +48,8 @@
|
|||||||
[(ngModel)]="displayName"
|
[(ngModel)]="displayName"
|
||||||
type="text"
|
type="text"
|
||||||
id="register-display-name"
|
id="register-display-name"
|
||||||
|
name="displayName"
|
||||||
|
appSelectOnFocus
|
||||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,6 +63,7 @@
|
|||||||
[(ngModel)]="password"
|
[(ngModel)]="password"
|
||||||
type="password"
|
type="password"
|
||||||
id="register-password"
|
id="register-password"
|
||||||
|
name="password"
|
||||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +76,7 @@
|
|||||||
<select
|
<select
|
||||||
[(ngModel)]="serverId"
|
[(ngModel)]="serverId"
|
||||||
id="register-server"
|
id="register-server"
|
||||||
|
name="serverId"
|
||||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
>
|
>
|
||||||
@for (s of servers(); track s.id) {
|
@for (s of servers(); track s.id) {
|
||||||
@@ -68,12 +88,12 @@
|
|||||||
<p class="text-xs text-destructive">{{ error() }}</p>
|
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
(click)="submit()"
|
type="submit"
|
||||||
type="button"
|
|
||||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{{ 'auth.register.submit' | translate }}
|
{{ 'auth.register.submit' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||||
{{ 'auth.register.haveAccount' | translate }}
|
{{ 'auth.register.haveAccount' | translate }}
|
||||||
<button
|
<button
|
||||||
@@ -86,4 +106,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
|
OnInit,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
@@ -15,10 +17,17 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
|
|
||||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
import {
|
||||||
|
AUTH_MODE_AUTHORIZE,
|
||||||
|
buildLoginReturnQueryParams,
|
||||||
|
isAuthorizeAuthMode,
|
||||||
|
resolveSafeReturnUrl,
|
||||||
|
waitForAuthenticationOutcome
|
||||||
|
} from '../../domain/logic/auth-navigation.rules';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||||
|
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-register',
|
selector: 'app-register',
|
||||||
@@ -27,6 +36,8 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
|
AutoFocusDirective,
|
||||||
|
SelectOnFocusDirective,
|
||||||
...APP_TRANSLATE_IMPORTS
|
...APP_TRANSLATE_IMPORTS
|
||||||
],
|
],
|
||||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||||
@@ -35,7 +46,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
|||||||
/**
|
/**
|
||||||
* Registration form allowing new users to create an account on a selected server.
|
* Registration form allowing new users to create an account on a selected server.
|
||||||
*/
|
*/
|
||||||
export class RegisterComponent {
|
export class RegisterComponent implements OnInit {
|
||||||
serversSvc = inject(ServerDirectoryFacade);
|
serversSvc = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
servers = this.serversSvc.servers;
|
servers = this.serversSvc.servers;
|
||||||
@@ -44,6 +55,13 @@ export class RegisterComponent {
|
|||||||
password = '';
|
password = '';
|
||||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
readonly isAuthorizeMode = signal(false);
|
||||||
|
readonly authorizeServerName = computed(() => {
|
||||||
|
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||||
|
const endpoint = this.servers().find((server) => server.id === sid);
|
||||||
|
|
||||||
|
return endpoint?.name ?? this.appI18n.instant('auth.authorize.defaultServerName');
|
||||||
|
});
|
||||||
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
private readonly appI18n = inject(AppI18nService);
|
||||||
private auth = inject(AuthenticationService);
|
private auth = inject(AuthenticationService);
|
||||||
@@ -55,6 +73,17 @@ export class RegisterComponent {
|
|||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const mode = this.route.snapshot.queryParamMap.get('mode');
|
||||||
|
const requestedServerId = this.route.snapshot.queryParamMap.get('serverId')?.trim();
|
||||||
|
|
||||||
|
this.isAuthorizeMode.set(isAuthorizeAuthMode(mode));
|
||||||
|
|
||||||
|
if (requestedServerId) {
|
||||||
|
this.serverId = requestedServerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Validate and submit the registration form, then navigate to search on success. */
|
/** Validate and submit the registration form, then navigate to search on success. */
|
||||||
submit() {
|
submit() {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
@@ -65,8 +94,24 @@ export class RegisterComponent {
|
|||||||
displayName: this.displayName.trim(),
|
displayName: this.displayName.trim(),
|
||||||
serverId: sid }).subscribe({
|
serverId: sid }).subscribe({
|
||||||
next: async (resp) => {
|
next: async (resp) => {
|
||||||
if (sid)
|
const serverUrl = this.auth.resolveServerUrlFor(sid);
|
||||||
|
|
||||||
|
if (this.isAuthorizeMode()) {
|
||||||
|
this.store.dispatch(UsersActions.authorizeSignalServer({
|
||||||
|
serverUrl,
|
||||||
|
response: resp,
|
||||||
|
provisioned: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||||
|
|
||||||
|
await this.router.navigateByUrl(returnUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sid) {
|
||||||
this.serversSvc.setActiveServer(sid);
|
this.serversSvc.setActiveServer(sid);
|
||||||
|
}
|
||||||
|
|
||||||
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
|
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
|
||||||
?? this.serversSvc.activeServer()?.url;
|
?? this.serversSvc.activeServer()?.url;
|
||||||
@@ -81,7 +126,7 @@ export class RegisterComponent {
|
|||||||
homeSignalServerUrl
|
homeSignalServerUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
this.store.dispatch(UsersActions.authenticateUser({ user, loginResponse: resp }));
|
||||||
|
|
||||||
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
|
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
|
||||||
|
|
||||||
@@ -90,14 +135,9 @@ export class RegisterComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||||
|
|
||||||
if (returnUrl?.startsWith('/')) {
|
|
||||||
await this.router.navigateByUrl(returnUrl);
|
await this.router.navigateByUrl(returnUrl);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.router.navigate(['/dashboard']);
|
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.error.set(err?.error?.error || this.appI18n.instant('auth.register.failed'));
|
this.error.set(err?.error?.error || this.appI18n.instant('auth.register.failed'));
|
||||||
@@ -107,10 +147,11 @@ export class RegisterComponent {
|
|||||||
|
|
||||||
/** Navigate to the login page. */
|
/** Navigate to the login page. */
|
||||||
goLogin() {
|
goLogin() {
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
|
||||||
|
|
||||||
this.router.navigate(['/login'], {
|
this.router.navigate(['/login'], {
|
||||||
queryParams: returnUrl ? { returnUrl } : undefined
|
queryParams: buildLoginReturnQueryParams(this.router.url, undefined, {
|
||||||
|
mode: this.isAuthorizeMode() ? AUTH_MODE_AUTHORIZE : undefined,
|
||||||
|
serverId: this.serverId
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
export * from './application/services/authentication.service';
|
export * from './application/services/authentication.service';
|
||||||
export * from './application/services/auth-token-store.service';
|
export * from './application/services/auth-token-store.service';
|
||||||
|
export * from './application/services/signal-server-auth.service';
|
||||||
|
export * from './application/services/signal-server-authorize.service';
|
||||||
|
export * from './application/services/signal-server-credential-store.service';
|
||||||
|
export * from './application/services/signal-server-provisioner.service';
|
||||||
|
export * from './application/services/signal-server-provision-notice.service';
|
||||||
|
export * from './application/services/provision-secret-store.service';
|
||||||
export * from './domain/models/authentication.model';
|
export * from './domain/models/authentication.model';
|
||||||
|
export * from './domain/models/signal-server-credential.model';
|
||||||
|
export * from './domain/logic/signal-server-provision.rules';
|
||||||
|
export * from './domain/logic/auth-navigation.rules';
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ export interface InventoryIntegritySnapshot {
|
|||||||
headHash: string;
|
headHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RemoteInventoryItem = {
|
export interface RemoteInventoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
rc?: number;
|
rc?: number;
|
||||||
ac?: number;
|
ac?: number;
|
||||||
revision?: number;
|
revision?: number;
|
||||||
headHash?: string;
|
headHash?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type MessageRevisionAction = MessageRevisionType;
|
export type MessageRevisionAction = MessageRevisionType;
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import {
|
|||||||
vi
|
vi
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import type { MessageRevision } from '../../../../shared-kernel';
|
import type { MessageRevision } from '../../../../shared-kernel';
|
||||||
import {
|
import { attachRevisionSignatureIfPossible, shouldAcceptRevisionWithoutRegisteredKey } from './message-revision-signing.rules';
|
||||||
attachRevisionSignatureIfPossible,
|
|
||||||
shouldAcceptRevisionWithoutRegisteredKey
|
|
||||||
} from './message-revision-signing.rules';
|
|
||||||
|
|
||||||
describe('message-revision-signing.rules', () => {
|
describe('message-revision-signing.rules', () => {
|
||||||
const revision: MessageRevision = {
|
const revision: MessageRevision = {
|
||||||
@@ -43,6 +40,7 @@ describe('message-revision-signing.rules', () => {
|
|||||||
...revision,
|
...revision,
|
||||||
signature: 'signature'
|
signature: 'signature'
|
||||||
}, null)).toBe(true);
|
}, null)).toBe(true);
|
||||||
|
|
||||||
expect(shouldAcceptRevisionWithoutRegisteredKey(revision, null)).toBe(false);
|
expect(shouldAcceptRevisionWithoutRegisteredKey(revision, null)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,21 @@ describe('message-revision.builder.rules', () => {
|
|||||||
expect(revision.content).toBe('edited');
|
expect(revision.content).toBe('edited');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('materializes message state from a revision', async () => {
|
it('materializes create revisions without an editedAt label timestamp', async () => {
|
||||||
|
const revision = await buildMessageRevision({
|
||||||
|
message: createMessage(),
|
||||||
|
type: 'create',
|
||||||
|
actorId: 'user-1',
|
||||||
|
editedAt: 1_000
|
||||||
|
});
|
||||||
|
const materialized = materializeMessageFromRevision(null, revision);
|
||||||
|
|
||||||
|
expect(materialized.timestamp).toBe(1_000);
|
||||||
|
expect(materialized.editedAt).toBeUndefined();
|
||||||
|
expect(materialized.revision).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes message state from an edit revision', async () => {
|
||||||
const revision = await buildMessageRevision({
|
const revision = await buildMessageRevision({
|
||||||
message: createMessage(),
|
message: createMessage(),
|
||||||
type: 'author-edit',
|
type: 'author-edit',
|
||||||
@@ -67,6 +81,7 @@ describe('message-revision.builder.rules', () => {
|
|||||||
|
|
||||||
expect(materialized.revision).toBe(1);
|
expect(materialized.revision).toBe(1);
|
||||||
expect(materialized.content).toBe('edited');
|
expect(materialized.content).toBe('edited');
|
||||||
|
expect(materialized.editedAt).toBe(2_000);
|
||||||
expect(materialized.headHash).toBe(revision.headHash);
|
expect(materialized.headHash).toBe(revision.headHash);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function materializeMessageFromRevision(
|
|||||||
senderId: revision.senderId,
|
senderId: revision.senderId,
|
||||||
senderName: revision.senderName ?? base.senderName,
|
senderName: revision.senderName ?? base.senderName,
|
||||||
content: revision.isDeleted ? DELETED_MESSAGE_CONTENT : (revision.content ?? base.content),
|
content: revision.isDeleted ? DELETED_MESSAGE_CONTENT : (revision.content ?? base.content),
|
||||||
editedAt: revision.editedAt,
|
editedAt: revision.type === 'create' ? undefined : revision.editedAt,
|
||||||
revision: revision.revision,
|
revision: revision.revision,
|
||||||
headHash: revision.headHash,
|
headHash: revision.headHash,
|
||||||
isDeleted: revision.isDeleted,
|
isDeleted: revision.isDeleted,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user