Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions docs/architecture/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,11 +467,15 @@ and whether the last-updated time is shown. The same settings card shows the dis
and open actions. Strings come from the `customerDisplay` message namespace.

An owner or manager can also pair a standalone device (a TV or tablet browser) from the same card.
Pairing creates a token and shows a `/customer-display/#token=...` link once. The board moves the
token from the URL fragment into `localStorage` and polls `GET /api/customer-display/feed` with it
instead of using a staff session, so it needs no login and can read nothing but order numbers and
statuses. When the feed answers `401` (the token was revoked), the board clears the stored token and
shows an unpaired screen.
The card loads the POS server's LAN addresses and uses the selected address for both the display URL
and pairing link. The primary LAN address is selected first; other network interfaces, mDNS, and a
clearly labelled local preview can be selected before pairing. Switching addresses updates the
one-time link without creating another token. If no LAN address is available, the card says so and
offers only the local preview; if address lookup fails, it shows an error and retry action. The board
moves the token from the URL fragment into `localStorage` and polls
`GET /api/customer-display/feed` with it instead of using a staff session, so it needs no login and
can read nothing but order numbers and statuses. When the feed answers `401` (the token was
revoked), the board clears the stored token and shows an unpaired screen.

## Tech stack

Expand Down
222 changes: 222 additions & 0 deletions frontend/e2e/customer-display-pairing-addresses.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { test, expect, type Page } from '@playwright/test';
import { E2E_BASE_URL as BASE } from './helpers/urls';

async function startSettingsSession(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('token', 'customer-display-address-test-token');
});
await page.route('**/api/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === '/api/auth/me') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: {
id: 'e2e-owner',
name: 'E2E Owner',
email: '[email protected]',
role: 'owner',
category_ids: [],
},
tenants: [
{
id: 1,
business_name: 'E2E Cafe',
role: 'owner',
plan: 'free',
status: 'active',
business_type: 'restaurant',
language: 'en',
},
],
}),
});
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
await page.goto(`${BASE}/auth/login`);
await page.waitForURL(/\/pos(?:\/|$)/, { timeout: 20000 });
}

const posInfo = {
mdns_url: 'http://flo.local:4321',
ip_url: 'http://10.42.0.25:4321',
ips_data: [
{ ip: '10.42.0.25', url: 'http://10.42.0.25:4321', qr_data: null },
{ ip: '192.168.1.25', url: 'http://192.168.1.25:4321', qr_data: null },
],
};

test('customer display pairing uses the selected POS address and reuses its token when switched', async ({
page,
browser,
}) => {
await startSettingsSession(page);
let networkInfoReads = 0;
let tokenCreates = 0;
const token = `cdt_${'a'.repeat(64)}`;
await page.route('**/api/pos-info', async (route) => {
networkInfoReads += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(posInfo),
});
});
await page.route('**/api/settings/customer-display/tokens', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"tokens":[]}' });
return;
}
tokenCreates += 1;
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'paired-display-1',
label: 'Counter TV',
token,
created_at: '2026-10-14T12:00:00.000Z',
}),
});
});
await page.goto(`${BASE}/settings?tab=pos`);
await page.getByRole('button', { name: 'Kitchen Display', exact: true }).click();
await expect(page.getByText('Primary LAN address', { exact: true })).toBeVisible();
await expect(page.getByRole('radio', { name: /Primary LAN address/ })).toBeChecked();
await expect(
page.getByText('http://10.42.0.25:4321/customer-display/', { exact: true }),
).toBeVisible();
expect(networkInfoReads).toBe(1);

await page.getByLabel('Display name', { exact: true }).fill('Counter TV');
await page.getByRole('button', { name: 'Pair display', exact: true }).click();
let pairingUrl = 'http://10.42.0.25:4321/customer-display/#token=' + token;
await expect(page.getByText(pairingUrl, { exact: true })).toBeVisible();

await page.getByText('Show other addresses', { exact: true }).click();
await page.getByRole('radio', { name: /192\.168\.1\.25:4321/ }).check();
await expect(
page.getByText('http://192.168.1.25:4321/customer-display/', { exact: true }),
).toBeVisible();
pairingUrl = 'http://192.168.1.25:4321/customer-display/#token=' + token;
await expect(page.getByText(pairingUrl, { exact: true })).toBeVisible();
await page.getByRole('radio', { name: /flo\.local:4321/ }).check();
pairingUrl = 'http://flo.local:4321/customer-display/#token=' + token;
await expect(page.getByText(pairingUrl, { exact: true })).toBeVisible();
await page.getByRole('radio', { name: /This computer only \(local preview\)/ }).check();
const localOrigin = new URL(BASE).origin;
await expect(page.getByText(`${localOrigin}/customer-display/`, { exact: true })).toBeVisible();
pairingUrl = `${localOrigin}/customer-display/#token=${token}`;
await expect(page.getByText(pairingUrl, { exact: true })).toBeVisible();
expect(tokenCreates).toBe(1);

const deviceContext = await browser.newContext();
const devicePage = await deviceContext.newPage();
let feedRequests = 0;
let revoked = false;
await devicePage.route('**/api/customer-display/feed', async (route) => {
feedRequests += 1;
if (revoked) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: '{"error":"revoked"}',
});
return;
}
expect(route.request().headers().authorization).toBe(`Bearer ${token}`);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
business_name: 'E2E Cafe',
config: {
statuses: ['pending', 'preparing', 'ready'],
refresh_seconds: 3,
show_updated: false,
},
orders: [{ id: 1, number: 'A-42', status: 'ready' }],
}),
});
});
await devicePage.goto(pairingUrl);
await expect(devicePage.getByText('E2E Cafe', { exact: true })).toBeVisible();
await expect(devicePage.getByText('A-42', { exact: true })).toBeVisible();
await expect(devicePage).toHaveURL(`${localOrigin}/customer-display/`);
await devicePage.reload();
await expect(devicePage.getByText('A-42', { exact: true })).toBeVisible();
expect(feedRequests).toBeGreaterThanOrEqual(2);
revoked = true;
await expect(
devicePage.getByRole('heading', { name: 'This display is not paired', exact: true }),
).toBeVisible({ timeout: 10000 });
await deviceContext.close();
});

test('customer display pairing shows no-LAN and network lookup failures explicitly', async ({
page,
}) => {
await startSettingsSession(page);
let networkInfoReads = 0;
const localToken = `cdt_${'b'.repeat(64)}`;
await page.route('**/api/pos-info', async (route) => {
networkInfoReads += 1;
if (networkInfoReads === 1) {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: '{"error":"offline"}',
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
mdns_url: 'http://flo.local:3001',
ip_url: 'http://127.0.0.1:3001',
ips_data: [{ ip: '127.0.0.1', url: 'http://127.0.0.1:3001', qr_data: null }],
}),
});
});
await page.route('**/api/settings/customer-display/tokens', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"tokens":[]}' });
return;
}
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'paired-display-local',
label: 'Local preview',
token: localToken,
created_at: '2026-10-14T12:00:00.000Z',
}),
});
});

await page.goto(`${BASE}/settings?tab=pos`);
await page.getByRole('button', { name: 'Kitchen Display', exact: true }).click();
await expect(page.getByText('Could not load network addresses.', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.getByRole('status')).toContainText(
'LAN pairing is unavailable. Select the local preview to use this computer only.',
);
await expect(page.locator('input[name="customer-display-pairing-address"]')).toHaveCount(1);
await expect(
page.getByRole('radio', { name: /This computer only \(local preview\)/ }),
).not.toBeChecked();
await expect(page.getByText('http://flo.local:3001', { exact: true })).toHaveCount(0);
await page.getByRole('radio', { name: /This computer only \(local preview\)/ }).check();
await page.getByLabel('Display name', { exact: true }).fill('Local preview');
await page.getByRole('button', { name: 'Pair display', exact: true }).click();
const localOrigin = new URL(BASE).origin;
await expect(
page.getByText(`${localOrigin}/customer-display/#token=${localToken}`, { exact: true }),
).toBeVisible();
expect(networkInfoReads).toBe(2);
});
10 changes: 10 additions & 0 deletions frontend/e2e/customer-display-pairing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ async function openKdsSettingsTab(page: Page): Promise<void> {
await expect(page.getByRole('heading', { name: 'Paired displays', exact: true })).toBeVisible({
timeout: 30_000,
});
const checkedAddress = page.locator('input[name="customer-display-pairing-address"]:checked');
const lanUnavailable = page.getByText(
'LAN pairing is unavailable. Select the local preview to use this computer only.',
{ exact: true },
);
await expect(checkedAddress.or(lanUnavailable).first()).toBeVisible({ timeout: 30_000 });
if ((await checkedAddress.count()) === 0) {
await page.getByRole('radio', { name: /This computer only \(local preview\)/ }).check();
await expect(checkedAddress).toHaveCount(1);
}
}

async function revokeAllDisplayTokens(request: APIRequestContext): Promise<void> {
Expand Down
4 changes: 3 additions & 1 deletion frontend/e2e/settings-request-budget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,7 +1258,9 @@ test('Network pairing cards explain local and VPN/mesh QR choices across setting
'Use when devices connect via a VPN or mesh network across different subnets or locations';
const expectLocalAddressCard = async () => {
const localAddress = 'http://10.42.0.25:3001';
const localCard = page.getByText(localAddress, { exact: true }).locator('xpath=..');
const localCard = page
.getByRole('link', { name: localAddress, exact: true })
.locator('xpath=..');
await expect(localCard.getByRole('link')).toHaveAttribute('href', localAddress);
await expect(localCard.getByText('Local Network', { exact: true })).toBeVisible();
await expect(localCard).toContainText(englishLocalHint);
Expand Down
Loading
Loading