fix: Major bug cleanup pass 1
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 8m12s
Queue Release Build / build-windows (push) Successful in 27m44s
Queue Release Build / build-linux (push) Successful in 48m1s
Queue Release Build / build-android (push) Successful in 22m7s
Queue Release Build / finalize (push) Successful in 2m42s

This commit is contained in:
2026-06-09 17:59:54 +02:00
parent 80d7728e66
commit eb51f043ac
127 changed files with 2731 additions and 322 deletions

View File

@@ -25,6 +25,27 @@ 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] ### 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. - **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.

View File

@@ -66,7 +66,22 @@ Require `Authorization: Bearer`:
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

20
e2e/helpers/app-menu.ts Normal file
View 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();
}

View File

@@ -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
View 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 });
}

View File

@@ -41,7 +41,6 @@ export async function createMultiDeviceScenario(
password: MULTI_DEVICE_PASSWORD password: MULTI_DEVICE_PASSWORD
}; };
const serverName = `Multi Device Server ${suffix}`; const serverName = `Multi Device Server ${suffix}`;
const clientA = await createClient(); const clientA = await createClient();
const clientB = await createClient(); const clientB = await createClient();
@@ -59,6 +58,7 @@ export async function createMultiDeviceScenario(
await searchA.createServer(serverName, { await searchA.createServer(serverName, {
description: options.serverDescription ?? 'Multi-device session coverage' description: options.serverDescription ?? 'Multi-device session coverage'
}); });
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(clientA.page, serverName); await waitForCurrentRoomName(clientA.page, serverName);
@@ -115,7 +115,8 @@ export async function expectCrossDeviceMessage(
await sender.sendMessage(message); await sender.sendMessage(message);
await expect.poll(async () => { await expect.poll(async () => {
return await receiver.getMessageItemByText(message).isVisible().catch(() => false); return await receiver.getMessageItemByText(message).isVisible()
.catch(() => false);
}, { timeout }).toBe(true); }, { timeout }).toBe(true);
} }
@@ -150,7 +151,15 @@ async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20
} }
export async function readClientInstanceId(page: Page): Promise<string | null> { export async function readClientInstanceId(page: Page): Promise<string | null> {
return page.evaluate(() => localStorage.getItem('metoyou.clientInstanceId')); 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> { export async function logoutFromMenu(page: Page): Promise<void> {
@@ -191,9 +200,14 @@ export async function expectPassiveVoiceOnDevice(
.getByText('In voice on another device', { exact: false }) .getByText('In voice on another device', { exact: false })
.isVisible() .isVisible()
.catch(() => false); .catch(() => false);
const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible().catch(() => false); const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible()
.catch(() => false);
const grayedVoiceUser = displayName const grayedVoiceUser = displayName
? await channelsSidePanel(page).locator('.opacity-50').filter({ hasText: displayName }).first().isVisible().catch(() => false) ? await channelsSidePanel(page).locator('.opacity-50')
.filter({ hasText: displayName })
.first()
.isVisible()
.catch(() => false)
: false; : false;
return membersLabel || joinBadge || grayedVoiceUser; return membersLabel || joinBadge || grayedVoiceUser;

View 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 });
}

View File

@@ -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 }
); );
} }

View File

@@ -1,7 +1,4 @@
import { import { test, expect } from '../../fixtures/multi-client';
test,
expect
} from '../../fixtures/multi-client';
import { import {
MULTI_DEVICE_VOICE_CHANNEL, MULTI_DEVICE_VOICE_CHANNEL,
channelsSidePanel, channelsSidePanel,
@@ -57,13 +54,17 @@ test.describe('Multi-device session', () => {
await expectPassiveVoiceOnDevice(scenario.clientB.page, { await expectPassiveVoiceOnDevice(scenario.clientB.page, {
displayName: scenario.credentials.displayName displayName: scenario.credentials.displayName
}); });
await expect( await expect(
membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false }) membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false })
).toBeVisible({ timeout: 20_000 }); ).toBeVisible({ timeout: 20_000 });
await expect( await expect(
channelsSidePanel(scenario.clientB.page).locator('.opacity-50').filter({ channelsSidePanel(scenario.clientB.page).locator('.opacity-50')
.filter({
hasText: scenario.credentials.displayName hasText: scenario.credentials.displayName
}).first() })
.first()
).toBeVisible({ timeout: 20_000 }); ).toBeVisible({ timeout: 20_000 });
}); });

View 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();
}
});
});

View File

@@ -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)}`;

View File

@@ -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 };
} }

View File

@@ -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 };
} }

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 ──

View File

@@ -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 });

View File

@@ -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 () => {

View 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;
}
}

View File

@@ -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());

View File

@@ -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);

View File

@@ -346,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 = {
@@ -502,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);

View File

@@ -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());

View 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);
});
});

View 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');
}

View File

@@ -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));

View File

@@ -7,7 +7,11 @@ import {
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import { connectedUsers } from './state'; import { connectedUsers } from './state';
import { ConnectedUser } from './types'; import { ConnectedUser } from './types';
import { broadcastToServer, findUserByOderId, findVoiceActiveConnection } from './broadcast'; import {
broadcastToServer,
findUserByOderId,
findVoiceActiveConnection
} from './broadcast';
function createMockWs(): WebSocket & { sentMessages: string[] } { function createMockWs(): WebSocket & { sentMessages: string[] } {
const sent: string[] = []; const sent: string[] = [];

View File

@@ -134,12 +134,14 @@ describe('server websocket handler - multi-client sessions', () => {
oderId: 'user-2', oderId: 'user-2',
serverIds: new Set(['server-1']) serverIds: new Set(['server-1'])
}); });
createConnectedUser('conn-passive', { createConnectedUser('conn-passive', {
authenticated: true, authenticated: true,
oderId: 'user-1', oderId: 'user-1',
serverIds: new Set(['server-1']), serverIds: new Set(['server-1']),
clientInstanceId: 'device-passive' clientInstanceId: 'device-passive'
}); });
const active = createConnectedUser('conn-active', { const active = createConnectedUser('conn-active', {
authenticated: true, authenticated: true,
oderId: 'user-1', oderId: 'user-1',
@@ -169,6 +171,7 @@ describe('server websocket handler - multi-client sessions', () => {
serverIds: new Set(['server-1']), serverIds: new Set(['server-1']),
clientInstanceId: 'device-b' clientInstanceId: 'device-b'
}); });
const active = createConnectedUser('conn-active', { const active = createConnectedUser('conn-active', {
authenticated: true, authenticated: true,
oderId: 'user-1', oderId: 'user-1',
@@ -197,6 +200,7 @@ describe('server websocket handler - multi-client sessions', () => {
connectionScope: 'ws://localhost:3001', connectionScope: 'ws://localhost:3001',
clientInstanceId: 'device-a' clientInstanceId: 'device-a'
}); });
createConnectedUser('conn-new', { createConnectedUser('conn-new', {
authenticated: false, authenticated: false,
connectionScope: 'ws://localhost:3001', connectionScope: 'ws://localhost:3001',

View File

@@ -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."
} }
} }
} }

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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": {
@@ -1013,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.",
@@ -1138,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": {
@@ -1161,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",

View File

@@ -323,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 & {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 });

View File

@@ -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');
});
});

View File

@@ -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('');
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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');
});
});

View File

@@ -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(/\/+$/, '');
}
}

View File

@@ -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);
}
}

View File

@@ -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();
});
});

View File

@@ -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(/\/+$/, '');
}
}

View File

@@ -9,6 +9,7 @@ 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 DEFAULT_POST_AUTH_URL = '/dashboard';
export const AUTH_MODE_AUTHORIZE = 'authorize';
const AUTH_ROUTE_PATHS = new Set(['/login', '/register']); const AUTH_ROUTE_PATHS = new Set(['/login', '/register']);
const MAX_RETURN_URL_DEPTH = 10; const MAX_RETURN_URL_DEPTH = 10;
@@ -79,15 +80,27 @@ export function resolveSafeReturnUrl(
export function buildLoginReturnQueryParams( export function buildLoginReturnQueryParams(
currentUrl: string, currentUrl: string,
fallback = DEFAULT_POST_AUTH_URL fallback = DEFAULT_POST_AUTH_URL,
): { returnUrl?: string } { extra: Record<string, string | undefined> = {}
): Record<string, string> {
const safeReturnUrl = resolveSafeReturnUrl(currentUrl, fallback); const safeReturnUrl = resolveSafeReturnUrl(currentUrl, fallback);
const queryParams: Record<string, string> = {};
if (safeReturnUrl === fallback) { if (safeReturnUrl !== fallback) {
return {}; queryParams['returnUrl'] = safeReturnUrl;
} }
return { 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(

View File

@@ -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', () => {

View File

@@ -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);
});
});

View File

@@ -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());
}

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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']);
});
});

View File

@@ -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)];
}

View File

@@ -0,0 +1,9 @@
export interface SignalServerCredential {
serverUrl: string;
userId: string;
username: string;
displayName: string;
token: string;
expiresAt: number;
provisioned: boolean;
}

View File

@@ -5,9 +5,19 @@
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>
@if (isAuthorizeMode()) {
<p class="text-xs text-muted-foreground mb-4">{{ 'auth.authorize.description' | translate }}</p>
}
<form <form
class="space-y-3" class="space-y-3"
(ngSubmit)="submit()" (ngSubmit)="submit()"

View File

@@ -1,6 +1,7 @@
import { import {
Component, Component,
computed,
inject, inject,
OnInit, OnInit,
signal signal
@@ -21,7 +22,9 @@ import {
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 { import {
AUTH_MODE_AUTHORIZE,
buildLoginReturnQueryParams, buildLoginReturnQueryParams,
isAuthorizeAuthMode,
resolveSafeReturnUrl, resolveSafeReturnUrl,
waitForAuthenticationOutcome waitForAuthenticationOutcome
} from '../../domain/logic/auth-navigation.rules'; } from '../../domain/logic/auth-navigation.rules';
@@ -56,6 +59,13 @@ export class LoginComponent implements OnInit {
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);
@@ -68,6 +78,19 @@ export class LoginComponent implements OnInit {
trackById(_index: number, item: { id: string }) { return item.id; } trackById(_index: number, item: { id: string }) { return item.id; }
ngOnInit(): void { 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( this.store.select(selectCurrentUser).pipe(
filter(Boolean), filter(Boolean),
take(1) take(1)
@@ -88,8 +111,24 @@ export class LoginComponent implements OnInit {
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;
@@ -104,7 +143,7 @@ export class LoginComponent implements OnInit {
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$));
@@ -126,7 +165,10 @@ export class LoginComponent implements OnInit {
/** Navigate to the registration page. */ /** Navigate to the registration page. */
goRegister() { goRegister() {
this.router.navigate(['/register'], { this.router.navigate(['/register'], {
queryParams: buildLoginReturnQueryParams(this.router.url) queryParams: buildLoginReturnQueryParams(this.router.url, undefined, {
mode: this.isAuthorizeMode() ? AUTH_MODE_AUTHORIZE : undefined,
serverId: this.serverId
})
}); });
} }
} }

View File

@@ -5,9 +5,19 @@
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>
@if (isAuthorizeMode()) {
<p class="text-xs text-muted-foreground mb-4">{{ 'auth.authorize.description' | translate }}</p>
}
<form <form
class="space-y-3" class="space-y-3"
(ngSubmit)="submit()" (ngSubmit)="submit()"

View File

@@ -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';
@@ -16,7 +18,9 @@ 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 { import {
AUTH_MODE_AUTHORIZE,
buildLoginReturnQueryParams, buildLoginReturnQueryParams,
isAuthorizeAuthMode,
resolveSafeReturnUrl, resolveSafeReturnUrl,
waitForAuthenticationOutcome waitForAuthenticationOutcome
} from '../../domain/logic/auth-navigation.rules'; } from '../../domain/logic/auth-navigation.rules';
@@ -42,7 +46,7 @@ import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/d
/** /**
* 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;
@@ -51,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);
@@ -62,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);
@@ -72,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;
@@ -88,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$));
@@ -110,7 +148,10 @@ export class RegisterComponent {
/** Navigate to the login page. */ /** Navigate to the login page. */
goLogin() { goLogin() {
this.router.navigate(['/login'], { this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url) queryParams: buildLoginReturnQueryParams(this.router.url, undefined, {
mode: this.isAuthorizeMode() ? AUTH_MODE_AUTHORIZE : undefined,
serverId: this.serverId
})
}); });
} }
} }

View File

@@ -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';

View File

@@ -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;

View File

@@ -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);
}); });
}); });

View File

@@ -7,11 +7,7 @@ import { findMissingIds } from './message-sync.rules';
describe('message-sync.rules', () => { describe('message-sync.rules', () => {
it('requests ids with newer revision or mismatched head hash', () => { it('requests ids with newer revision or mismatched head hash', () => {
const localMap = new Map([ const localMap = new Map([['m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }], ['m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }]]);
['m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }],
['m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }]
]);
const missing = findMissingIds([ const missing = findMissingIds([
{ id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' }, { id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' },
{ id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }, { id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' },

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */ /* eslint-disable @typescript-eslint/member-ordering, */
import { import {
Component, Component,
computed, computed,

View File

@@ -94,6 +94,7 @@ describe('CustomEmojiPickerComponent', () => {
} }
] ]
}); });
initializeAppI18nForTests(injector); initializeAppI18nForTests(injector);
return runInInjectionContext(injector, () => injector.get(CustomEmojiPickerComponent)); return runInInjectionContext(injector, () => injector.get(CustomEmojiPickerComponent));

View File

@@ -22,10 +22,7 @@ import {
import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel'; import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel';
import { CustomEmojiService } from '../../application/custom-emoji.service'; import { CustomEmojiService } from '../../application/custom-emoji.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import { import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
AutoFocusDirective,
SelectOnFocusDirective
} from '../../../../shared/directives';
import { import {
CUSTOM_EMOJI_ACCEPT_ATTRIBUTE, CUSTOM_EMOJI_ACCEPT_ATTRIBUTE,
UNICODE_EMOJI_PICKER_ENTRIES, UNICODE_EMOJI_PICKER_ENTRIES,

View File

@@ -9,7 +9,11 @@ import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { MobileCallSessionService, MobileMediaService, MobileNotificationsService } from '../../../../infrastructure/mobile'; import {
MobileCallSessionService,
MobileMediaService,
MobileNotificationsService
} from '../../../../infrastructure/mobile';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing'; import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { import {
@@ -575,6 +579,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
...provideAppI18nForTests() ...provideAppI18nForTests()
] ]
}); });
initializeAppI18nForTests(injector); initializeAppI18nForTests(injector);
return { return {

View File

@@ -21,10 +21,7 @@ import {
VoiceConnectionFacade, VoiceConnectionFacade,
VoicePlaybackService VoicePlaybackService
} from '../../../voice-connection'; } from '../../../voice-connection';
import { import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
VoiceSessionFacade,
isVoiceOnAnotherClient
} from '../../../voice-session';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message'; import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import type { DirectMessageConversation } from '../../../direct-message'; import type { DirectMessageConversation } from '../../../direct-message';

View File

@@ -17,10 +17,7 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
AutoFocusDirective,
SelectOnFocusDirective
} from '../../../../shared/directives';
import { UserSearchListComponent } from '../user-search-list/user-search-list.component'; import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
import { selectAllUsers } from '../../../../store/users/users.selectors'; import { selectAllUsers } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';

View File

@@ -15,7 +15,11 @@ import type { User } from '../../../../shared-kernel';
@Component({ @Component({
selector: 'app-friend-button', selector: 'app-friend-button',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS], imports: [
CommonModule,
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideUserCheck, lucideUserPlus })], viewProviders: [provideIcons({ lucideUserCheck, lucideUserPlus })],
templateUrl: './friend-button.component.html' templateUrl: './friend-button.component.html'
}) })

View File

@@ -23,7 +23,11 @@ import { ExperimentalVlcPlayerHandle, ExperimentalVlcRuntimeService } from '../.
@Component({ @Component({
selector: 'app-experimental-vlc-player', selector: 'app-experimental-vlc-player',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS], imports: [
CommonModule,
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideDownload, lucideDownload,

View File

@@ -232,6 +232,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
} }
] ]
}); });
initializeAppI18nForTests(injector); initializeAppI18nForTests(injector);
const service = runInInjectionContext(injector, () => new GameActivityService()); const service = runInInjectionContext(injector, () => new GameActivityService());
const context = { const context = {

View File

@@ -21,7 +21,11 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../../core/i18n';
@Component({ @Component({
selector: 'app-notifications-settings', selector: 'app-notifications-settings',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS], imports: [
CommonModule,
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideBell, lucideBell,

View File

@@ -125,6 +125,7 @@ describe('PluginClientApiService', () => {
}) })
}) })
); );
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({ expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'chat-message', type: 'chat-message',
message: expect.objectContaining({ message: expect.objectContaining({
@@ -132,6 +133,7 @@ describe('PluginClientApiService', () => {
roomId: 'room-1' roomId: 'room-1'
}) })
})); }));
expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled(); expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled();
}); });

View File

@@ -17,6 +17,7 @@ import type {
LocalPluginRegistrationResult, LocalPluginRegistrationResult,
RegisteredPlugin RegisteredPlugin
} from '../../domain/models/plugin-runtime.models'; } from '../../domain/models/plugin-runtime.models';
import { isSecurePluginRemoteUrl } from '../../domain/rules/plugin-source-url.rules';
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service'; import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
import { PluginCapabilityService } from './plugin-capability.service'; import { PluginCapabilityService } from './plugin-capability.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service'; import { PluginDesktopStateService } from './plugin-desktop-state.service';
@@ -24,10 +25,7 @@ import { PluginClientApiService } from './plugin-client-api.service';
import { PluginLoggerService } from './plugin-logger.service'; import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service'; import { PluginRegistryService } from './plugin-registry.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service'; import { PluginUiRegistryService } from './plugin-ui-registry.service';
import { import { fileUrlToPath, grantPluginReadRoots } from '../../domain/rules/plugin-local-file.rules';
fileUrlToPath,
grantPluginReadRoots
} from '../../domain/rules/plugin-local-file.rules';
interface ActivePluginRuntime { interface ActivePluginRuntime {
context: TojuPluginActivationContext; context: TojuPluginActivationContext;
@@ -379,7 +377,7 @@ export class PluginHostService {
return { module, moduleObjectUrl }; return { module, moduleObjectUrl };
} }
if (!entrypointUrl.startsWith('file://') && !entrypointUrl.startsWith('https://')) { if (!entrypointUrl.startsWith('file://') && !isSecurePluginRemoteUrl(entrypointUrl)) {
throw new Error('Remote plugin entrypoints must use HTTPS'); throw new Error('Remote plugin entrypoints must use HTTPS');
} }
@@ -388,7 +386,7 @@ export class PluginHostService {
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
}; };
} catch (error) { } catch (error) {
if (!entrypointUrl.startsWith('https://')) { if (!isSecurePluginRemoteUrl(entrypointUrl)) {
throw error; throw error;
} }
@@ -420,7 +418,7 @@ export class PluginHostService {
} }
private async createRemoteModuleObjectUrl(entrypointUrl: string, manifest: TojuPluginManifest): Promise<string> { private async createRemoteModuleObjectUrl(entrypointUrl: string, manifest: TojuPluginManifest): Promise<string> {
if (!entrypointUrl.startsWith('https://')) { if (!isSecurePluginRemoteUrl(entrypointUrl)) {
throw new Error('Remote plugin entrypoints must use HTTPS'); throw new Error('Remote plugin entrypoints must use HTTPS');
} }

View File

@@ -238,6 +238,22 @@ describe('PluginStoreService', () => {
url: plugin.readmeUrl url: plugin.readmeUrl
}); });
}); });
it('allows localhost HTTP plugin source URLs for local dev and E2E', async () => {
const localSourceUrl = 'http://localhost:4200/plugins/e2e-plugin-source.json';
mockFetchResponses(fetchMock, {
[localSourceUrl]: jsonResponse({
title: 'Local E2E Source',
plugins: []
})
});
const service = createService(registerLocalManifest, unregister);
await expect(service.addSourceUrl(localSourceUrl)).resolves.toBeUndefined();
expect(service.sourceUrls()).toContain(localSourceUrl);
});
}); });
function mockFetchResponses(fetchMock: ReturnType<typeof vi.fn>, responses: Record<string, Response>): void { function mockFetchResponses(fetchMock: ReturnType<typeof vi.fn>, responses: Record<string, Response>): void {
@@ -312,8 +328,8 @@ function createService(
} }
] ]
}); });
const service = injector.get(PluginStoreService); const service = injector.get(PluginStoreService);
injector.get(AppI18nService).initialize(); injector.get(AppI18nService).initialize();
return service; return service;

View File

@@ -11,6 +11,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../../../environments/environment'; import { environment } from '../../../../../environments/environment';
import { isLocalDevPluginSourceUrl } from '../../domain/rules/plugin-source-url.rules';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AppI18nService } from '../../../../core/i18n'; import { AppI18nService } from '../../../../core/i18n';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage'; import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
@@ -45,10 +46,7 @@ import { PluginCapabilityService } from './plugin-capability.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service'; import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service'; import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service'; import { PluginRegistryService } from './plugin-registry.service';
import { import { fileUrlToPath, grantPluginReadRoots } from '../../domain/rules/plugin-local-file.rules';
fileUrlToPath,
grantPluginReadRoots
} from '../../domain/rules/plugin-local-file.rules';
const STORE_SCHEMA_VERSION = 2; const STORE_SCHEMA_VERSION = 2;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store'; const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
@@ -172,8 +170,8 @@ export class PluginStoreService {
} }
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]); this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
await this.ensurePluginSourceReadRoot(sourceUrl);
this.saveState(); this.saveState();
await this.ensurePluginSourceReadRoot(sourceUrl);
await this.refreshSources(); await this.refreshSources();
} }
@@ -514,7 +512,7 @@ export class PluginStoreService {
return await this.readLocalFileUrl(url); return await this.readLocalFileUrl(url);
} }
if (!url.startsWith('https://')) { if (!url.startsWith('https://') && !isLocalDevPluginSourceUrl(url)) {
throw new Error('Remote plugin store requests must use HTTPS'); throw new Error('Remote plugin store requests must use HTTPS');
} }

View File

@@ -1,4 +1,8 @@
import { describe, expect, it } from 'vitest'; import {
describe,
expect,
it
} from 'vitest';
import { import {
collectPluginReadRoots, collectPluginReadRoots,
fileUrlToPath, fileUrlToPath,
@@ -15,18 +19,12 @@ describe('plugin-local-file.rules', () => {
expect(collectPluginReadRoots( expect(collectPluginReadRoots(
'file:///home/ludde/Desktop/TestPlugin/plugin-source.json', 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json',
'file:///home/ludde/Desktop/TestPlugin/dist/main.js' 'file:///home/ludde/Desktop/TestPlugin/dist/main.js'
)).toEqual([ )).toEqual(['/home/ludde/Desktop/TestPlugin', '/home/ludde/Desktop/TestPlugin/dist']);
'/home/ludde/Desktop/TestPlugin',
'/home/ludde/Desktop/TestPlugin/dist'
]);
}); });
it('treats directory file URLs as their own read roots', () => { it('treats directory file URLs as their own read roots', () => {
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin/')).toEqual([ expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin/')).toEqual(['/home/ludde/Desktop/TestPlugin']);
'/home/ludde/Desktop/TestPlugin'
]); expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin')).toEqual(['/home/ludde/Desktop/TestPlugin']);
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin')).toEqual([
'/home/ludde/Desktop/TestPlugin'
]);
}); });
}); });

View File

@@ -8,7 +8,8 @@ export function pluginFileParentDir(filePath: string): string {
} }
export function pluginReadRootForFileUrl(fileUrl: string): string { export function pluginReadRootForFileUrl(fileUrl: string): string {
const filePath = fileUrlToPath(fileUrl).replace(/\\/g, '/').replace(/\/+$/, ''); const filePath = fileUrlToPath(fileUrl).replace(/\\/g, '/')
.replace(/\/+$/, '');
const basename = filePath.split('/').pop() ?? ''; const basename = filePath.split('/').pop() ?? '';
if (fileUrl.endsWith('/') || !basename.includes('.')) { if (fileUrl.endsWith('/') || !basename.includes('.')) {
@@ -18,7 +19,7 @@ export function pluginReadRootForFileUrl(fileUrl: string): string {
return pluginFileParentDir(filePath); return pluginFileParentDir(filePath);
} }
export function collectPluginReadRoots(...fileUrls: Array<string | undefined>): string[] { export function collectPluginReadRoots(...fileUrls: (string | undefined)[]): string[] {
const roots = new Set<string>(); const roots = new Set<string>();
for (const fileUrl of fileUrls) { for (const fileUrl of fileUrls) {
@@ -34,7 +35,7 @@ export function collectPluginReadRoots(...fileUrls: Array<string | undefined>):
export async function grantPluginReadRoots( export async function grantPluginReadRoots(
api: Pick<ElectronApi, 'grantPluginReadRoot'> | null | undefined, api: Pick<ElectronApi, 'grantPluginReadRoot'> | null | undefined,
...fileUrls: Array<string | undefined> ...fileUrls: (string | undefined)[]
): Promise<void> { ): Promise<void> {
if (!api?.grantPluginReadRoot) { if (!api?.grantPluginReadRoot) {
return; return;

View File

@@ -0,0 +1,16 @@
import { isLocalDevPluginSourceUrl, isSecurePluginRemoteUrl } from './plugin-source-url.rules';
describe('plugin source URL rules', () => {
it('treats localhost HTTP URLs as local dev plugin sources', () => {
expect(isLocalDevPluginSourceUrl('http://localhost:4200/plugins/e2e-plugin-source.json')).toBe(true);
expect(isLocalDevPluginSourceUrl('http://127.0.0.1:4200/plugins/e2e-plugin-source.json')).toBe(true);
expect(isLocalDevPluginSourceUrl('http://example.com/plugins.json')).toBe(false);
expect(isLocalDevPluginSourceUrl('https://localhost/plugins.json')).toBe(false);
});
it('accepts HTTPS and localhost HTTP as secure remote plugin URLs', () => {
expect(isSecurePluginRemoteUrl('https://plugins.example.test/index.json')).toBe(true);
expect(isSecurePluginRemoteUrl('http://localhost:4200/plugins/e2e-plugin-source.json')).toBe(true);
expect(isSecurePluginRemoteUrl('http://example.com/plugins.json')).toBe(false);
});
});

View File

@@ -0,0 +1,14 @@
export function isLocalDevPluginSourceUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:'
&& (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1');
} catch {
return false;
}
}
export function isSecurePluginRemoteUrl(url: string): boolean {
return url.startsWith('https://') || isLocalDevPluginSourceUrl(url);
}

View File

@@ -23,7 +23,11 @@ import {
@Component({ @Component({
selector: 'app-profile-avatar-editor', selector: 'app-profile-avatar-editor',
standalone: true, standalone: true,
imports: [CommonModule, ModalBackdropComponent, ...APP_TRANSLATE_IMPORTS], imports: [
CommonModule,
ModalBackdropComponent,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './profile-avatar-editor.component.html' templateUrl: './profile-avatar-editor.component.html'
}) })
export class ProfileAvatarEditorComponent { export class ProfileAvatarEditorComponent {

View File

@@ -29,6 +29,7 @@ import {
import { ServerEndpointCompatibilityService } from '../../infrastructure/services/server-endpoint-compatibility.service'; import { ServerEndpointCompatibilityService } from '../../infrastructure/services/server-endpoint-compatibility.service';
import { ServerEndpointHealthService } from '../../infrastructure/services/server-endpoint-health.service'; import { ServerEndpointHealthService } from '../../infrastructure/services/server-endpoint-health.service';
import { ServerEndpointStateService } from './server-endpoint-state.service'; import { ServerEndpointStateService } from './server-endpoint-state.service';
import { SignalServerAuthService } from '../../../authentication/application/services/signal-server-auth.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ServerDirectoryService { export class ServerDirectoryService {
@@ -41,6 +42,7 @@ export class ServerDirectoryService {
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService); private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
private readonly endpointHealth = inject(ServerEndpointHealthService); private readonly endpointHealth = inject(ServerEndpointHealthService);
private readonly api = inject(ServerDirectoryApiService); private readonly api = inject(ServerDirectoryApiService);
private readonly signalServerAuth = inject(SignalServerAuthService);
private readonly initialServerHealthCheck: Promise<void>; private readonly initialServerHealthCheck: Promise<void>;
private shouldSearchAllServers = true; private shouldSearchAllServers = true;
@@ -217,6 +219,10 @@ export class ServerDirectoryService {
healthResult.serverTag healthResult.serverTag
); );
if (healthResult.status === 'online' && endpoint.isActive) {
void this.signalServerAuth.ensureProvisioned(endpoint.url).catch(() => undefined);
}
return healthResult.status === 'online'; return healthResult.status === 'online';
} }
@@ -286,7 +292,13 @@ export class ServerDirectoryService {
request: ServerJoinAccessRequest, request: ServerJoinAccessRequest,
selector?: ServerSourceSelector selector?: ServerSourceSelector
): Observable<ServerJoinAccessResponse> { ): Observable<ServerJoinAccessResponse> {
return this.api.requestJoin(request, selector); const actorUserId = this.resolveActorUserId(request.userId, selector);
return this.api.requestJoin({
...request,
userId: actorUserId,
userPublicKey: actorUserId
}, selector);
} }
createInvite( createInvite(
@@ -326,7 +338,7 @@ export class ServerDirectoryService {
} }
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> { notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
return this.api.notifyLeave(serverId, userId, selector); return this.api.notifyLeave(serverId, this.resolveActorUserId(userId, selector), selector);
} }
updateUserCount(serverId: string, count: number): Observable<void> { updateUserCount(serverId: string, count: number): Observable<void> {
@@ -353,4 +365,27 @@ export class ServerDirectoryService {
this.shouldSearchAllServers = true; this.shouldSearchAllServers = true;
} }
} }
private resolveActorUserId(userId: string, selector?: ServerSourceSelector): string {
return this.signalServerAuth.resolveActorUserIdForServer(
this.resolveSelectorServerUrl(selector),
userId
);
}
private resolveSelectorServerUrl(selector?: ServerSourceSelector): string | undefined {
const sourceUrl = selector?.sourceUrl?.trim();
if (sourceUrl) {
return this.endpointState.sanitiseUrl(sourceUrl);
}
const sourceId = selector?.sourceId?.trim();
if (sourceId) {
return this.servers().find((endpoint) => endpoint.id === sourceId)?.url;
}
return this.activeServer()?.url;
}
} }

View File

@@ -20,10 +20,7 @@ import { ServerDirectoryFacade } from '../../application/facades/server-director
import { ThemeNodeDirective } from '../../../theme'; import { ThemeNodeDirective } from '../../../theme';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent, ModalBackdropComponent } from '../../../../shared'; import { BottomSheetComponent, ModalBackdropComponent } from '../../../../shared';
import { import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
AutoFocusDirective,
SelectOnFocusDirective
} from '../../../../shared/directives';
import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-server.component'; import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-server.component';
/** /**
@@ -114,6 +111,7 @@ export class CreateServerDialogComponent {
this.router.navigate(['/login'], { this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url) queryParams: buildLoginReturnQueryParams(this.router.url)
}); });
return; return;
} }

View File

@@ -52,6 +52,7 @@ function createHarness(options: HarnessOptions = {}) {
...provideAppI18nForTests() ...provideAppI18nForTests()
] ]
}); });
initializeAppI18nForTests(injector); initializeAppI18nForTests(injector);
const component = runInInjectionContext(injector, () => injector.get(FindServersComponent)); const component = runInInjectionContext(injector, () => injector.get(FindServersComponent));

View File

@@ -18,6 +18,7 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules'; import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
@Component({ @Component({
selector: 'app-invite', selector: 'app-invite',
@@ -26,19 +27,22 @@ import { buildLoginReturnQueryParams } from '../../../authentication/domain/logi
templateUrl: './invite.component.html' templateUrl: './invite.component.html'
}) })
export class InviteComponent implements OnInit { export class InviteComponent implements OnInit {
private readonly i18n = inject(AppI18nService);
readonly currentUser = inject(Store).selectSignal(selectCurrentUser); readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
readonly invite = signal<ServerInviteInfo | null>(null); readonly invite = signal<ServerInviteInfo | null>(null);
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading'); readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
readonly message = signal(this.i18n.instant('servers.invite.messages.loading')); readonly message = signal('');
private readonly i18n = inject(AppI18nService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly databaseService = inject(DatabaseService); private readonly databaseService = inject(DatabaseService);
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
this.message.set(this.i18n.instant('servers.invite.messages.loading'));
const inviteContext = this.resolveInviteContext(); const inviteContext = this.resolveInviteContext();
if (!inviteContext) { if (!inviteContext) {
@@ -127,6 +131,15 @@ export class InviteComponent implements OnInit {
this.message.set(this.i18n.instant('servers.invite.messages.joining', { name: invite.server.name })); this.message.set(this.i18n.instant('servers.invite.messages.joining', { name: invite.server.name }));
const currentUser = await this.hydrateCurrentUser(); const currentUser = await this.hydrateCurrentUser();
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(context.sourceUrl);
if (!hasCredential) {
this.status.set('redirecting');
this.message.set(this.i18n.instant('servers.invite.messages.redirectingAuthorize'));
return;
}
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({ const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: invite.server.id, roomId: invite.server.id,
userId: currentUserId, userId: currentUserId,

View File

@@ -114,6 +114,7 @@ function createHarness(options: HarnessOptions = {}) {
...provideAppI18nForTests() ...provideAppI18nForTests()
] ]
}); });
initializeAppI18nForTests(injector); initializeAppI18nForTests(injector);
const component = runInInjectionContext(injector, () => injector.get(ServerBrowserComponent)); const component = runInInjectionContext(injector, () => injector.get(ServerBrowserComponent));

View File

@@ -32,6 +32,7 @@ import {
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage'; import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules'; import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives'; import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { import {
@@ -124,6 +125,7 @@ export class ServerBrowserComponent implements OnInit {
private pluginStore = inject(PluginStoreService); private pluginStore = inject(PluginStoreService);
private injector = inject(Injector); private injector = inject(Injector);
private readonly i18n = inject(AppI18nService); private readonly i18n = inject(AppI18nService);
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
private searchSubject = new Subject<string>(); private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0; private banLookupRequestVersion = 0;
@@ -530,6 +532,14 @@ export class ServerBrowserComponent implements OnInit {
} }
} }
if (server.sourceUrl) {
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(server.sourceUrl);
if (!hasCredential) {
return;
}
}
const response = await firstValueFrom( const response = await firstValueFrom(
this.serverDirectory.requestJoin( this.serverDirectory.requestJoin(
{ {

View File

@@ -28,6 +28,7 @@ describe('ThemeService theme application', () => {
useValue: createDocumentStub(styleElements) useValue: createDocumentStub(styleElements)
} }
]); ]);
initializeAppI18nForTests(injector); initializeAppI18nForTests(injector);
service = injector.get(ThemeService); service = injector.get(ThemeService);

View File

@@ -347,7 +347,12 @@ export class ThemeService {
return false; return false;
} }
this.commitTheme(result.value, stringifyTheme(result.value), this.appI18n.instant('theme.status.presetApplied', { name: result.value.meta.name })); this.commitTheme(
result.value,
stringifyTheme(result.value),
this.appI18n.instant('theme.status.presetApplied', { name: result.value.meta.name })
);
return true; return true;
} }

View File

@@ -12,10 +12,7 @@ import { ThemeRegistryService } from '../../application/services/theme-registry.
@Component({ @Component({
selector: 'app-theme-picker-overlay', selector: 'app-theme-picker-overlay',
standalone: true, standalone: true,
imports: [ imports: [CommonModule, ...APP_TRANSLATE_IMPORTS],
CommonModule,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './theme-picker-overlay.component.html' templateUrl: './theme-picker-overlay.component.html'
}) })
export class ThemePickerOverlayComponent { export class ThemePickerOverlayComponent {

View File

@@ -1,4 +1,8 @@
import { describe, expect, it } from 'vitest'; import {
describe,
expect,
it
} from 'vitest';
import type { VoiceState } from '../../../../shared-kernel'; import type { VoiceState } from '../../../../shared-kernel';
import { import {
isLocalVoiceOwner, isLocalVoiceOwner,

View File

@@ -45,10 +45,7 @@ import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../
import { ScreenShareQualityDialogComponent } from '../../shared'; import { ScreenShareQualityDialogComponent } from '../../shared';
import { ViewportService } from '../../core/platform'; import { ViewportService } from '../../core/platform';
import { RealtimeSessionFacade } from '../../core/realtime'; import { RealtimeSessionFacade } from '../../core/realtime';
import { import { isLocalVoiceOwner, isVoiceOnAnotherClient } from '../../domains/voice-session';
isLocalVoiceOwner,
isVoiceOnAnotherClient
} from '../../domains/voice-session';
import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile'; import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import { UsersActions } from '../../store/users/users.actions'; import { UsersActions } from '../../store/users/users.actions';

View File

@@ -32,6 +32,8 @@ import {
lucidePackage lucidePackage
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors'; import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import { SignalServerAuthService } from '../../../domains/authentication/application/services/signal-server-auth.service';
import { isSelfPresenceUserId } from '../../../domains/authentication/domain/logic/self-presence-identity.rules';
import { import {
selectCurrentRoom, selectCurrentRoom,
selectActiveChannelId, selectActiveChannelId,
@@ -140,6 +142,7 @@ const SKELETON_REVEAL_DELAY_MS = 180;
}) })
export class RoomsSidePanelComponent implements OnDestroy { export class RoomsSidePanelComponent implements OnDestroy {
private store = inject(Store); private store = inject(Store);
private signalServerAuth = inject(SignalServerAuthService);
private router = inject(Router); private router = inject(Router);
private realtime = inject(RealtimeSessionFacade); private realtime = inject(RealtimeSessionFacade);
private voiceConnection = inject(VoiceConnectionFacade); private voiceConnection = inject(VoiceConnectionFacade);
@@ -208,7 +211,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
this.addIdentifiers(onlineIdentifiers, user); this.addIdentifiers(onlineIdentifiers, user);
} }
this.addIdentifiers(onlineIdentifiers, this.currentUser()); this.addSelfPresenceIdentifiers(onlineIdentifiers);
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member)); return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
}); });
@@ -408,10 +411,33 @@ export class RoomsSidePanelComponent implements OnDestroy {
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean { private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
const current = this.currentUser(); const current = this.currentUser();
return ( if (!current) {
!!current && return false;
((typeof entity.id === 'string' && entity.id === current.id) || (typeof entity.oderId === 'string' && entity.oderId === current.oderId)) }
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
current,
this.currentRoom()?.sourceUrl
); );
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
}
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
const current = this.currentUser();
if (!current) {
return;
}
this.addIdentifiers(identifiers, current);
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
current,
this.currentRoom()?.sourceUrl
)) {
identifiers.add(selfId);
}
} }
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void { private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {

View File

@@ -280,6 +280,7 @@ export class ServersRailComponent {
this.router.navigate(['/login'], { this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url) queryParams: buildLoginReturnQueryParams(this.router.url)
}); });
return; return;
} }

View File

@@ -22,7 +22,11 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
@Component({ @Component({
selector: 'app-bans-settings', selector: 'app-bans-settings',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS], imports: [
CommonModule,
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideX lucideX

View File

@@ -23,7 +23,11 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
@Component({ @Component({
selector: 'app-data-settings', selector: 'app-data-settings',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS], imports: [
CommonModule,
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideDatabase, lucideDatabase,

View File

@@ -31,7 +31,11 @@ const APP_METRICS_POLL_INTERVAL_MS = 2_000;
@Component({ @Component({
selector: 'app-debugging-settings', selector: 'app-debugging-settings',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS], imports: [
CommonModule,
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideBug, lucideBug,

View File

@@ -14,10 +14,7 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
import { PlatformService } from '../../../../core/platform'; import { PlatformService } from '../../../../core/platform';
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service'; import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shared/directives';
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
@Component({ @Component({
selector: 'app-general-settings', selector: 'app-general-settings',

View File

@@ -83,7 +83,7 @@
type="button" type="button"
(click)="removeEntry(entry.id)" (click)="removeEntry(entry.id)"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-destructive/10" class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
[title]="'settings.network.ice.moveDown' | translate" [title]="'settings.network.ice.remove' | translate"
> >
<ng-icon <ng-icon
name="lucideTrash2" name="lucideTrash2"

View File

@@ -81,6 +81,7 @@ export class IceServerSettingsComponent {
? 'settings.network.ice.errors.urlPrefixStun' ? 'settings.network.ice.errors.urlPrefixStun'
: 'settings.network.ice.errors.urlPrefixTurn' : 'settings.network.ice.errors.urlPrefixTurn'
)); ));
return; return;
} }

Some files were not shown because too many files have changed in this diff Show More