diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..29a3726 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,28 @@ +name: e2e + +on: + push: + branches: [main, feat/security] + pull_request: + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 12.3.4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm xmpp:up + - run: sleep 5 + - run: pnpm test:e2e + - run: pnpm build + - run: pnpm test:csp + - if: always() + run: pnpm xmpp:down diff --git a/CHANGELOG.md b/CHANGELOG.md index 69210aa..b114ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,24 @@ without a deprecation period. - Read state survives a refresh: delivered/read receipts are stored locally and re-applied when MAM replays the conversation. Add a Settings > Chats toggle to stop sending read receipts. +- "Encrypt new direct chats by default" is now a guided switch: turning it on + before OMEMO is ready scrolls to setup/unlock and focuses the passphrase, and + the default is saved only once OMEMO can encrypt. +- Settings > Security keeps only secondary passphrase-storage mechanics in a + tooltip. Warnings, current state, and irreversible consequences stay visible. +- Added Repair keys (top up pre-keys and republish without changing identity) and + Reset OMEMO (new identity/device, fresh passphrase, old device removed from + the PEP list) to Security settings. +- Nested dialogs opened from Settings (such as Reset OMEMO) now render a + dimming overlay above the parent dialog instead of inheriting its muted layer. +- Added repo Playwright smoke suites: `pnpm test:e2e` covers mutual presence, + DM/group delivery, group invite/join, reactions (including refresh restore), + transcript scrolling, and panel restore; `pnpm test:csp` checks the static + build for CSP violations. Added a GitHub Actions workflow for both. +- OMEMO placeholders received while locked now decrypt automatically after + unlock or device verification. Outgoing encrypted carbons from your other + devices are decrypted and shown in the recipient conversation without + duplicating the originating device's copy. - Reaction tooltips expose tooltip semantics, and the group "Not encrypted" badge becomes icon-only on narrow screens so group names fit. - README diagrams now show the federation trust boundary and the message/receipt diff --git a/dev/xmpp/README.md b/dev/xmpp/README.md index e5344f9..52b261b 100644 --- a/dev/xmpp/README.md +++ b/dev/xmpp/README.md @@ -43,3 +43,30 @@ pnpm test:integration ``` The test connects two Stanza clients, sends a message, and waits for the delivery receipt. + +## End-to-end smoke + +The live Playwright smoke exercises two accounts, mutual presence, DM delivery, +group invite/join, group reactions (including refresh restore), transcript +scrolling, the URL-backed info panel, and console errors. + +```bash +pnpm exec playwright install chromium # once +pnpm xmpp:up +pnpm test:e2e +``` + +The script starts `pnpm dev` itself if the app is not already running. Set +`APP_URL`, `XMPP_WS`, or `XMPP_PASSWORD` to target a different setup. It uses +`phiey@localhost` and `sam@localhost` from the compose file. + +CSP smoke runs against the static build: + +```bash +pnpm build +pnpm test:csp +``` + +It serves `.output/public` locally, logs in with a test-only `ws://` allowance +for the local Prosody, opens Settings > Security, and fails on any +`securitypolicyviolation`, page error, or console error. diff --git a/docs/backlog.md b/docs/backlog.md index 9859100..c7b642d 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -54,10 +54,11 @@ Deferred work lives here instead of `TODO` comments. Each item should include en and sends only encrypted envelopes, settings/contact UI expose device fingerprints and trust (including your other devices), and the composer has a per-chat lock. Remaining: - outgoing carbon decryption (currently ignored to avoid duplicates), - re-decrypting locked placeholders after unlock, encrypted replies/edits, - forwarding, attachments, and MAM history, live device-list PEP subscriptions, - and independent audit. Per-chat encryption choices are persisted. + encrypted replies/edits, forwarding, attachments, and MAM history, live + device-list PEP subscriptions, and independent audit. Per-chat encryption + choices are persisted; locked placeholders retry automatically after unlock + or device verification; outgoing encrypted carbons are decrypted without + duplicating the originating device's copy. - **OMEMO2 PEP (XEP-0384)** — legacy 0.3.0 device lists and bundles are implemented. Add the `urn:xmpp:omemo:2` device-list/bundle nodes and version-aware selection after 1:1 messaging works. diff --git a/docs/threat-model.md b/docs/threat-model.md index a78778b..a1a2679 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -58,7 +58,7 @@ Trust boundaries: | T10 | Lost passphrase | Keys are unrecoverable by design. No backup. | Operational. Users must understand this before setup. | | T11 | Contact's device key changes (MITM or new device) | Trust is bound to the reviewed fingerprint. Identity changes block reuse until reviewed. | Trust-on-review is only as good as the user's comparison. | | T16 | Group messages inferred as encrypted | Group chats show a "Not encrypted" badge with a tooltip; the composer exposes no group encryption toggle. | None for group confidentiality until group OMEMO lands. | -| T12 | Silent downgrade to plaintext for OMEMO chats | No automatic plaintext fallback. Sending unencrypted requires a confirmation dialog and shows an **Unencrypted** marker. Per-chat encryption choices persist per account, including explicit off, so a refresh cannot silently reset a chat to the default. | Users can still choose plaintext. | +| T12 | Silent downgrade to plaintext for OMEMO chats | No automatic plaintext fallback. Sending unencrypted requires a confirmation dialog and shows an **Unencrypted** marker. Per-chat encryption choices persist per account, including explicit off, so a refresh cannot silently reset a chat to the default. The default itself can only be enabled once OMEMO is set up and unlocked. | Users can still choose plaintext. | | T13 | Dependency compromise (`libomemo.js`, GPLv3, unaudited) | Isolated in `src/lib/omemo`. OMEMO labeled experimental. | High until audited. | | T14 | Secrets in logs | Production code does not log passwords, passphrases, or raw stanzas. The render error boundary logs the error and component stack (`console.error`). | Low. Error objects could contain user text. Review before release. | | T15 | Cross-account replay of OMEMO envelopes or unlock keys | Account JID bound as AAD; remembered keys are per account and fail on another account. | Low. Covered by tests. | diff --git a/package.json b/package.json index c5fec0a..2b78be5 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "xmpp:down": "docker compose -f dev/xmpp/compose.yml down", "xmpp:wipe": "docker compose -f dev/xmpp/compose.yml down -v", "test:integration": "XMPP_INTEGRATION=1 vitest run src/lib/xmpp/client.integration.test.ts src/lib/xmpp/omemo-pep.integration.test.ts src/lib/xmpp/omemo-message.integration.test.ts src/lib/xmpp/group-invite.integration.test.ts src/lib/xmpp/group-reactions.integration.test.ts", - "react:doctor": "pnpm dlx react-doctor@latest ." + "react:doctor": "pnpm dlx react-doctor@latest .", + "test:e2e": "node scripts/e2e/live-smoke.mjs", + "test:csp": "node scripts/e2e/csp-smoke.mjs" }, "dependencies": { "@base-ui/react": "^1.9.0", @@ -75,6 +77,7 @@ "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.0.1", "jsdom": "^30.1.3", + "playwright": "1.64.1", "shadcn": "^4.22.1", "typescript": "^6.0.2", "vite": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2652e73..2f75be3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: jsdom: specifier: ^30.1.3 version: 30.1.3 + playwright: + specifier: 1.64.1 + version: 1.64.1 shadcn: specifier: ^4.22.1 version: 4.22.1 @@ -2906,6 +2909,16 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -6428,6 +6441,12 @@ snapshots: dependencies: find-up: 3.0.0 + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 diff --git a/scripts/e2e/csp-smoke.mjs b/scripts/e2e/csp-smoke.mjs new file mode 100644 index 0000000..c5f9444 --- /dev/null +++ b/scripts/e2e/csp-smoke.mjs @@ -0,0 +1,134 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; +import { chromium } from "playwright"; + +const PORT = Number(process.env.CSP_PORT ?? 4173); +const BASE = `http://localhost:${PORT}`; +const WS = process.env.XMPP_WS ?? "ws://localhost:5280/xmpp-websocket"; +const BUILD_INDEX = ".output/public/index.html"; + +async function waitFor(check, timeoutMs, label) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (await check()) return; + await delay(100); + } + throw new Error(`Timed out waiting for ${label}`); +} + +async function isUp(url) { + try { + const response = await fetch(url); + return response.ok; + } catch { + return false; + } +} + +function collect(page, violations, errors) { + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error" && !message.text().includes("favicon")) { + errors.push(message.text()); + } + }); + page.on("framenavigated", async () => { + const found = await page.evaluate(() => window.__cspViolations ?? []).catch(() => []); + violations.push(...found); + }); +} + +async function login(page) { + await page.goto(`${BASE}/connect`, { waitUntil: "networkidle" }); + await page.locator("#jid").fill("phiey@localhost"); + await page.locator("#password").fill("bob123"); + await page.getByRole("button", { name: "Connection options" }).click(); + await page.locator("#websocket").fill(WS); + await page.getByRole("button", { name: "Done" }).click(); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await page.waitForURL(/\/chat/, { timeout: 20_000 }); + await page.waitForTimeout(800); +} + +async function run() { + if (!existsSync(BUILD_INDEX)) { + throw new Error("Missing static build. Run pnpm build first."); + } + if (await isUp(BASE)) throw new Error(`Port ${PORT} is already in use.`); + const server = spawn("node", ["scripts/serve-static.mjs"], { + env: { ...process.env, PORT: String(PORT) }, + stdio: "ignore", + }); + const violations = []; + const errors = []; + const browser = await chromium.launch({ + headless: true, + args: ["--disable-features=LocalNetworkAccessChecks", "--disable-dev-shm-usage"], + }); + try { + await waitFor(() => isUp(`${BASE}/connect`), 20_000, "static server"); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + await context.route("**/*", async (route) => { + if (route.request().resourceType() !== "document") return route.continue(); + const response = await route.fetch(); + const body = (await response.text()).replace( + "connect-src 'self' wss: https:", + "connect-src 'self' wss: https: ws://localhost:5280", + ); + await route.fulfill({ + response, + body, + headers: { ...response.headers(), "content-type": "text/html; charset=utf-8" }, + }); + }); + const page = await context.newPage(); + await page.addInitScript(() => { + window.__cspViolations = []; + document.addEventListener("securitypolicyviolation", (event) => { + window.__cspViolations.push({ + directive: event.violatedDirective, + blocked: event.blockedURI, + }); + }); + }); + collect(page, violations, errors); + + for (const path of ["/", "/connect"]) { + await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" }); + violations.push(...(await page.evaluate(() => window.__cspViolations ?? []))); + await page.waitForTimeout(300); + } + + await login(page); + await page.locator('nav[aria-label="Primary"] button[aria-label="Settings"]').click({ force: true }); + await page.getByRole("dialog").waitFor(); + await page.getByRole("button", { name: /^Security$/ }).click(); + await page.waitForTimeout(700); + violations.push(...(await page.evaluate(() => window.__cspViolations ?? []))); + + const state = await page.evaluate(() => ({ + cspMeta: document.querySelectorAll('meta[http-equiv="Content-Security-Policy"]').length, + styleSheets: document.styleSheets.length, + hasSecurity: (document.body.innerText || "").includes("Security"), + violations: window.__cspViolations ?? [], + })); + + const unique = [...new Map(violations.map((item) => [`${item.directive}|${item.blocked}`, item])).values()]; + if (state.cspMeta !== 1) throw new Error("CSP meta tag is missing"); + if (state.styleSheets === 0) throw new Error("Stylesheets did not load"); + if (!state.hasSecurity) throw new Error("Settings > Security did not render"); + if (unique.length > 0) throw new Error(`CSP violations: ${JSON.stringify(unique)}`); + if (errors.length > 0) throw new Error(`Console/page errors: ${errors.join(" | ")}`); + + console.log(JSON.stringify({ ok: true, cspMeta: state.cspMeta, styleSheets: state.styleSheets }, null, 2)); + } finally { + await browser.close().catch(() => undefined); + server.kill("SIGTERM"); + } +} + +run().catch((error) => { + console.error("CSP E2E FAILED:", error.message); + process.exitCode = 1; +}); diff --git a/scripts/e2e/helpers.mjs b/scripts/e2e/helpers.mjs new file mode 100644 index 0000000..85f9811 --- /dev/null +++ b/scripts/e2e/helpers.mjs @@ -0,0 +1,146 @@ +import { setTimeout as delay } from "node:timers/promises"; + +export const APP_URL = process.env.APP_URL ?? "http://localhost:3000"; +export const XMPP_WS = + process.env.XMPP_WS ?? "ws://localhost:5280/xmpp-websocket"; +export const PASSWORD = process.env.XMPP_PASSWORD ?? "bob123"; + +export function browserOptions() { + return { + headless: true, + args: [ + "--disable-features=LocalNetworkAccessChecks", + "--disable-dev-shm-usage", + ], + }; +} + +export function collectErrors(page) { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + return errors; +} + +export async function waitFor(check, timeoutMs = 20_000, label = "condition") { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (await check()) return; + await delay(100); + } + throw new Error(`Timed out waiting for ${label}`); +} + +export async function login(context, jid) { + const page = await context.newPage(); + const errors = collectErrors(page); + await page.goto(`${APP_URL}/connect`, { waitUntil: "networkidle" }); + await page.locator("#jid").fill(jid); + await page.locator("#password").fill(PASSWORD); + await page.getByRole("button", { name: "Connection options" }).click(); + await page.locator("#websocket").fill(XMPP_WS); + await page.getByRole("button", { name: "Done" }).click(); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await page.waitForURL(/\/chat/, { timeout: 20_000 }); + await page.waitForTimeout(800); + return { page, errors }; +} + +export async function acceptContactRequest(page) { + const section = page.getByText("Contact requests").locator("xpath=.."); + if ((await section.count()) === 0) return false; + const accept = section.getByRole("button", { name: "Accept" }).first(); + if ((await accept.count()) === 0) return false; + await accept.click(); + await page.waitForTimeout(500); + return true; +} + +async function addContact(page, peerJid) { + await page.getByRole("button", { name: "Add contact" }).click(); + await page.waitForTimeout(300); + await page.locator("#contact-jid").fill(peerJid); + await page.getByRole("button", { name: "Send request" }).click(); + await page.waitForTimeout(700); +} + +export async function ensureContact(owner, peer, ownerJid, peerJid) { + await owner.page.goto(`${APP_URL}/chat`, { waitUntil: "networkidle" }); + await owner.page.waitForTimeout(500); + const existing = await owner.page + .locator(`a[href*="${encodeURIComponent(peerJid)}"]`) + .count(); + if (existing === 0) await addContact(owner.page, peerJid); + + await peer.page.waitForTimeout(700); + await acceptContactRequest(peer.page); + await owner.page.waitForTimeout(700); + await acceptContactRequest(owner.page); + await peer.page.waitForTimeout(300); + await acceptContactRequest(peer.page); + + await owner.page.goto(`${APP_URL}/chat/${encodeURIComponent(peerJid)}`, { + waitUntil: "networkidle", + }); + await peer.page.goto(`${APP_URL}/chat/${encodeURIComponent(ownerJid)}`, { + waitUntil: "networkidle", + }); + await waitFor( + async () => (await owner.page.locator("body").innerText()).includes("Online"), + 15_000, + "owner presence", + ); + await waitFor( + async () => (await peer.page.locator("body").innerText()).includes("Online"), + 15_000, + "peer presence", + ); +} + +export async function sendMessage(page, body) { + const composer = page.locator("textarea").last(); + await composer.fill(body); + await composer.press("Enter"); + await page.waitForTimeout(250); +} + +export async function scrollMetrics(page) { + return page.evaluate(() => { + const viewport = document.querySelector('[aria-label="Messages"]'); + const spacer = document.querySelector("[data-message-scroller-spacer]"); + const articles = [...document.querySelectorAll('[role="article"]')]; + const last = articles.at(-1); + const viewportRect = viewport?.getBoundingClientRect(); + const lastRect = last?.getBoundingClientRect(); + return { + spacerHidden: + spacer instanceof HTMLElement ? spacer.hidden : null, + blankBelowLast: + viewportRect && lastRect + ? Math.round(viewportRect.bottom - lastRect.bottom) + : null, + }; + }); +} + +export async function createGroup(owner, peerName, roomName) { + await owner.page.goto(`${APP_URL}/chat`, { waitUntil: "networkidle" }); + await owner.page.getByRole("button", { name: "New group" }).click(); + await owner.page.locator("#group-name").fill(roomName); + const dialog = owner.page.getByRole("dialog"); + await dialog.getByRole("button", { name: new RegExp(peerName, "i") }).first().click(); + await dialog.getByRole("button", { name: "Create group" }).click(); + await owner.page.waitForURL(/\/chat\/group\//, { timeout: 20_000 }); + await owner.page.waitForTimeout(800); + return owner.page.url().split("?")[0]; +} + +export async function acceptGroupInvite(page, roomName) { + const item = page.locator("li").filter({ hasText: roomName }).first(); + await item.waitFor({ timeout: 20_000 }); + await item.getByRole("button", { name: "Accept" }).click(); + await page.waitForURL(/\/chat\/group\//, { timeout: 20_000 }); + await page.waitForTimeout(800); +} diff --git a/scripts/e2e/live-smoke.mjs b/scripts/e2e/live-smoke.mjs new file mode 100644 index 0000000..40e254e --- /dev/null +++ b/scripts/e2e/live-smoke.mjs @@ -0,0 +1,197 @@ +import { spawn } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; +import { chromium } from "playwright"; +import { + APP_URL, + acceptGroupInvite, + browserOptions, + createGroup, + ensureContact, + login, + scrollMetrics, + sendMessage, + waitFor, +} from "./helpers.mjs"; + +const OWNER = "phiey@localhost"; +const PEER = "sam@localhost"; + +async function isUp(url) { + try { + const response = await fetch(url); + return response.ok; + } catch { + return false; + } +} + +async function startDevIfNeeded() { + if (await isUp(`${APP_URL}/connect`)) return null; + const child = spawn("pnpm", ["dev"], { + detached: true, + stdio: "ignore", + }); + await waitFor( + () => isUp(`${APP_URL}/connect`), + 60_000, + "dev server", + ); + return child; +} + +function stop(child) { + if (!child) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } +} + +async function checkXmpp() { + await new Promise((resolve, reject) => { + const socket = new WebSocket( + process.env.XMPP_WS ?? "ws://localhost:5280/xmpp-websocket", + "xmpp", + ); + const timer = setTimeout(() => { + socket.close(); + reject(new Error("XMPP WebSocket did not open. Run pnpm xmpp:up.")); + }, 8_000); + socket.onopen = () => { + clearTimeout(timer); + socket.close(); + resolve(); + }; + socket.onerror = () => { + clearTimeout(timer); + reject(new Error("XMPP WebSocket unavailable. Run pnpm xmpp:up.")); + }; + }); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function relevantErrors(errors) { + return errors.filter((error) => !error.includes("favicon")); +} + +async function run() { + const failures = []; + const dev = await startDevIfNeeded(); + const browser = await chromium.launch(browserOptions()); + try { + await checkXmpp(); + const owner = await login(await browser.newContext({ viewport: { width: 1280, height: 900 } }), OWNER); + const peer = await login(await browser.newContext({ viewport: { width: 1280, height: 900 } }), PEER); + + await ensureContact(owner, peer, OWNER, PEER); + + const dmMarker = `smoke dm ${Date.now()}`; + await sendMessage(owner.page, dmMarker); + await waitFor( + async () => (await peer.page.locator("body").innerText()).includes(dmMarker), + 10_000, + "DM delivery", + ); + + const roomName = `Smoke ${Date.now()}`; + const roomUrl = await createGroup(owner, "sam", roomName); + await peer.page.goto(`${APP_URL}/chat`, { waitUntil: "networkidle" }); + await peer.page.waitForTimeout(600); + await acceptGroupInvite(peer.page, roomName); + + const groupMarker = `smoke group ${Date.now()}`; + await sendMessage(owner.page, groupMarker); + await waitFor( + async () => (await peer.page.locator("body").innerText()).includes(groupMarker), + 10_000, + "group delivery", + ); + + const article = peer.page.locator('[role="article"]').last(); + await article.hover(); + await article.getByRole("button", { name: "Add reaction" }).click(); + await peer.page.getByRole("button", { name: "React with 👍" }).click(); + await waitFor( + async () => (await peer.page.locator('[data-slot="message-reactions"] button[aria-pressed="true"]').count()) > 0, + 10_000, + "local reaction", + ); + await waitFor( + async () => { + const chip = owner.page + .locator('[data-slot="message-reactions"] button') + .first(); + const label = await chip.getAttribute("aria-label").catch(() => null); + return Boolean(label?.includes("sam")); + }, + 10_000, + "reaction author label on the other side", + ); + + await peer.page.reload({ waitUntil: "networkidle" }); + await peer.page.waitForTimeout(1_200); + await waitFor( + async () => (await peer.page.locator('[data-slot="message-reactions"]').count()) > 0, + 10_000, + "reaction after refresh", + ); + + for (let index = 0; index < 6; index += 1) { + await sendMessage(owner.page, `burst ${index} ${Date.now()}`); + } + await owner.page.waitForTimeout(2_000); + const metrics = await scrollMetrics(owner.page); + assert(metrics.spacerHidden === true, `transcript spacer is visible: ${JSON.stringify(metrics)}`); + assert((metrics.blankBelowLast ?? 999) < 60, `blank transcript space: ${metrics.blankBelowLast}px`); + + await owner.page.locator("header button:has(h2)").first().click(); + await owner.page.waitForTimeout(600); + assert(owner.page.url().includes("?panel="), "group info did not open in the URL"); + assert(!owner.page.url().includes("%253A"), "panel URL is double-encoded"); + await owner.page.reload({ waitUntil: "networkidle" }); + await owner.page.waitForTimeout(900); + assert( + (await owner.page.locator("body").innerText()).includes("Members"), + "group info did not restore after refresh", + ); + + const errors = [...relevantErrors(owner.errors), ...relevantErrors(peer.errors)]; + assert(errors.length === 0, `console/page errors: ${errors.join(" | ")}`); + + console.log( + JSON.stringify( + { + ok: true, + room: roomUrl, + checks: { + dmDelivery: true, + mutualPresence: true, + groupDelivery: true, + reaction: true, + reactionAfterRefresh: true, + transcriptSpacer: metrics.spacerHidden, + panelRestore: true, + }, + }, + null, + 2, + ), + ); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + console.error("E2E FAILED:", failures[0]); + } finally { + await browser.close().catch(() => undefined); + stop(dev); + } + if (failures.length > 0) process.exitCode = 1; +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/components/chat/conversation/header/conversation-header/conversation-header.tsx b/src/components/chat/conversation/header/conversation-header/conversation-header.tsx index dcf692f..d1f38cc 100644 --- a/src/components/chat/conversation/header/conversation-header/conversation-header.tsx +++ b/src/components/chat/conversation/header/conversation-header/conversation-header.tsx @@ -5,17 +5,15 @@ import { } from "@remixicon/react"; import { HeaderActions } from "#/components/chat/conversation/header/header-actions/header-actions"; import { UserAvatar } from "#/components/common/user-avatar/user-avatar"; -import { Badge } from "#/components/ui/badge"; import { Button } from "#/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "#/components/ui/tooltip"; +import { conversationStatus } from "#/lib/format/conversation-status"; import { presenceDotClassName } from "#/lib/format/presence"; -import { formatLastSeen } from "#/lib/format/time"; import { cn } from "#/lib/utils"; -import { presenceLabel } from "#/lib/xmpp/presence"; import { useRightSidebar } from "#/providers/right-sidebar-provider"; import type { ConversationKind } from "#/types/chat"; import type { PresenceState } from "#/types/xmpp"; @@ -45,17 +43,13 @@ export function ConversationHeader({ }) { const { open } = useRightSidebar(); const isGroup = kind === "group"; - const status = isGroup - ? memberPreview - ? "Invite-only group" - : memberCount === 1 - ? "1 member" - : `${memberCount ?? 0} members` - : typing - ? "typing…" - : presence.show === "offline" && presence.lastSeen - ? formatLastSeen(presence.lastSeen) - : presence.status || presenceLabel(presence); + const status = conversationStatus({ + kind, + memberPreview, + memberCount, + typing, + presence, + }); function openInfo() { open(isGroup ? { kind: "room", jid } : { kind: "contact", jid }); @@ -74,54 +68,56 @@ export function ConversationHeader({ )} - +
+

{name}

+

{status}

+
+ + + {isGroup && ( + + + } + > + + + + Group messages are sent without end-to-end encryption. + + + )} + {emoji} {users.length > 1 && ( - + {users.length} )} diff --git a/src/components/common/info-tooltip/info-tooltip.tsx b/src/components/common/info-tooltip/info-tooltip.tsx new file mode 100644 index 0000000..1ec3835 --- /dev/null +++ b/src/components/common/info-tooltip/info-tooltip.tsx @@ -0,0 +1,50 @@ +import { RiInformationLine } from "@remixicon/react"; +import type { ReactNode } from "react"; +import { Button } from "#/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/ui/tooltip"; +import { cn } from "#/lib/utils"; + +/** + * Small info affordance for details that should not take permanent space in a + * dense settings panel. The trigger is a real button so keyboard and screen + * readers can reveal the same text. + */ +export function InfoTooltip({ + label, + children, + className, + side = "top", +}: { + label: string; + children: ReactNode; + className?: string; + side?: "top" | "bottom" | "left" | "right"; +}) { + return ( + + + } + > + + + + {children} + + + ); +} diff --git a/src/components/settings/settings-chats/settings-chats.tsx b/src/components/settings/settings-chats/settings-chats.tsx index 398d3df..b31bd62 100644 --- a/src/components/settings/settings-chats/settings-chats.tsx +++ b/src/components/settings/settings-chats/settings-chats.tsx @@ -4,7 +4,6 @@ import { Switch } from "#/components/ui/switch"; import { useSettings } from "#/hooks/use-settings"; import { setEnterToSend, - setReadReceipts, setTypingIndicators, } from "#/lib/settings/preferences"; @@ -38,17 +37,6 @@ export function SettingsChats() { /> } /> - - } - /> ); } diff --git a/src/components/settings/settings-omemo-card/settings-omemo-card.tsx b/src/components/settings/settings-omemo-card/settings-omemo-card.tsx index cd7ef57..0194064 100644 --- a/src/components/settings/settings-omemo-card/settings-omemo-card.tsx +++ b/src/components/settings/settings-omemo-card/settings-omemo-card.tsx @@ -8,6 +8,8 @@ import { import { type FormEvent, useState } from "react"; import { toast } from "react-hot-toast"; import { ContactInfoOmemo } from "#/components/chat/conversation/header/contact-info-panel/contact-info-omemo"; +import { InfoTooltip } from "#/components/common/info-tooltip/info-tooltip"; +import { SettingsOmemoResetDialog } from "#/components/settings/settings-omemo-reset-dialog/settings-omemo-reset-dialog"; import { Badge } from "#/components/ui/badge"; import { Button } from "#/components/ui/button"; import { Input } from "#/components/ui/input"; @@ -51,12 +53,26 @@ export function SettingsOmemoCard() { const [passphrase, setPassphrase] = useState(""); const [confirmation, setConfirmation] = useState(""); const [busy, setBusy] = useState(false); + const [maintenance, setMaintenance] = useState(false); + const [resetOpen, setResetOpen] = useState(false); const setupMode = omemo.status === "setup"; const unlockMode = omemo.status === "locked"; const ready = omemo.status === "ready"; const working = busy || omemo.status === "unlocking"; + async function repairKeys() { + setMaintenance(true); + try { + const ok = await omemo.repair(); + if (ok) { + toast.success("Device keys republished.", { id: "omemo-repair" }); + } + } finally { + setMaintenance(false); + } + } + async function submitSetup(event: FormEvent) { event.preventDefault(); const issue = passphraseIssue(passphrase); @@ -109,21 +125,25 @@ export function SettingsOmemoCard() { {STATUS_LABELS[omemo.status]} -

- Experimental and unaudited. Direct messages only. Encryption is off - by default and never falls back to plaintext. +

+ Experimental · unaudited · direct chats only

-
- -

+

+ +

{security.rememberOmemoUnlock - ? "Your passphrase is never stored. This browser keeps a non-exportable key that unlocks OMEMO until you lock, log out, or the session auto-locks. It expires after 24 hours. The passphrase cannot be recovered if lost." - : "Your passphrase is never stored and cannot be recovered. Losing it locks this device's OMEMO keys."}{" "} - Sessions lock after 10 minutes of inactivity. + ? "Passphrase cannot be recovered. Remembered unlock expires after 24 hours." + : "Passphrase cannot be recovered. Sessions lock after 10 minutes."}

+ + The passphrase is never stored.{" "} + {security.rememberOmemoUnlock + ? "This browser keeps a non-exportable key until you lock, log out, or the session auto-locks." + : "Losing it locks this device's OMEMO keys."} +
{omemo.status === "disabled" && ( @@ -215,16 +235,35 @@ export function SettingsOmemoCard() { button in the composer, then trust the contact's device in their contact info before sending.

- +
+ + + +
{ownJid && ( )} + +
); } diff --git a/src/components/settings/settings-omemo-card/settings-omemo-preferences.tsx b/src/components/settings/settings-omemo-card/settings-omemo-preferences.tsx index 4b920b0..1e46189 100644 --- a/src/components/settings/settings-omemo-card/settings-omemo-preferences.tsx +++ b/src/components/settings/settings-omemo-card/settings-omemo-preferences.tsx @@ -1,42 +1,89 @@ +import { useEffect, useRef } from "react"; +import { toast } from "react-hot-toast"; import { SettingRow } from "#/components/settings/setting-row/setting-row"; -import { - NativeSelect, - NativeSelectOption, -} from "#/components/ui/native-select"; import { Switch } from "#/components/ui/switch"; import { useSettings } from "#/hooks/use-settings"; import { setDefaultEncryption, setRememberOmemoUnlock, } from "#/lib/settings/preferences"; +import { useOmemo } from "#/providers/omemo-provider"; -/** Default encryption behavior and tab-scoped unlock preference. */ +/** + * Guided default-encryption switch. Turning it on before OMEMO is ready moves + * the user to setup/unlock and only saves the default once OMEMO can encrypt. + */ export function SettingsOmemoPreferences() { const { security } = useSettings(); + const omemo = useOmemo(); + const pendingDefault = useRef(false); + const enabled = security.defaultEncryption === "omemo"; + const ready = omemo.status === "ready"; + const connectable = omemo.status !== "disabled"; + + useEffect(() => { + if (!pendingDefault.current || omemo.status !== "ready") return; + pendingDefault.current = false; + setDefaultEncryption("omemo"); + toast.success("New direct chats will start encrypted.", { + id: "omemo-default", + }); + }, [omemo.status]); + + function toggleDefault(next: boolean) { + if (!connectable) return; + if (!next) { + pendingDefault.current = false; + setDefaultEncryption("none"); + return; + } + if (ready) { + setDefaultEncryption("omemo"); + return; + } + pendingDefault.current = true; + toast("Set up or unlock OMEMO to turn this on.", { + id: "omemo-default", + }); + window.requestAnimationFrame(() => { + document + .getElementById("settings-omemo") + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + document + .querySelector( + "#omemo-setup-passphrase, #omemo-unlock-passphrase", + ) + ?.focus(); + }); + } + + const description = !connectable + ? "Connect to XMPP first." + : !enabled + ? "Off. Turning this on will ask you to set up OMEMO." + : ready + ? "On. New one-to-one chats start encrypted." + : omemo.status === "locked" + ? "On. Unlock OMEMO to send encrypted messages." + : "On. Finish setting up OMEMO to send encrypted messages."; return (
{ - setDefaultEncryption( - event.target.value === "omemo" ? "omemo" : "none", - ); - }} - > - None - OMEMO - + } /> void; +}) { + const omemo = useOmemo(); + const [passphrase, setPassphrase] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [busy, setBusy] = useState(false); + + function changeOpen(next: boolean) { + if (busy) return; + if (!next) { + setPassphrase(""); + setConfirmation(""); + } + onOpenChange(next); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + const issue = passphraseIssue(passphrase); + if (issue) { + toast.error(passphraseIssueMessage(issue), { id: "omemo-reset" }); + return; + } + if (passphrase !== confirmation) { + toast.error("The passphrases do not match.", { id: "omemo-reset" }); + return; + } + setBusy(true); + try { + const ok = await omemo.reset(passphrase); + if (!ok) return; + toast.success("OMEMO reinitialized with a new identity.", { + id: "omemo-reset", + }); + setPassphrase(""); + setConfirmation(""); + onOpenChange(false); + } finally { + setBusy(false); + } + } + + return ( + + + + Reset OMEMO? + + This replaces your encryption identity and cannot be undone. + + + +
+ +
    +
  • Your device ID and fingerprint change.
  • +
  • Contacts must verify your new device before trusting it.
  • +
  • Old encrypted history on this device cannot be read.
  • +
  • Remembered unlock is cleared.
  • +
+
+ +
+ + + New passphrase + + setPassphrase(event.target.value)} + placeholder={`At least ${MIN_PASSPHRASE_LENGTH} characters`} + disabled={busy} + /> + + + + Confirm new passphrase + + setConfirmation(event.target.value)} + placeholder="Repeat the passphrase" + disabled={busy} + /> + +
+ + +
+
+ + +
+
+ ); +} diff --git a/src/components/settings/settings-security/settings-security.tsx b/src/components/settings/settings-security/settings-security.tsx index ede4645..6f7ab25 100644 --- a/src/components/settings/settings-security/settings-security.tsx +++ b/src/components/settings/settings-security/settings-security.tsx @@ -48,21 +48,18 @@ export function SettingsSecurity({ anchor }: { anchor?: "omemo" }) { title="Transport" value="TLS" tone="default" - description="Messages travel over WSS or HTTPS when your server uses a secure endpoint." /> } title="End-to-end encryption" value="Opt-in" tone="secondary" - description="OMEMO can be enabled per direct conversation after you set up and unlock it below." /> } title="OpenPGP" value="Deferred" tone="outline" - description="XEP-0374 support is a later interoperability option, not enabled here." />
@@ -78,9 +75,8 @@ export function SettingsSecurity({ anchor }: { anchor?: "omemo" }) {

- Do not use this development build for sensitive conversations. TLS - protects the connection, not the message contents. OMEMO is experimental - and has not been independently audited. + Development preview. OMEMO is experimental and unaudited. Do not use + this build for sensitive conversations.

); @@ -90,28 +86,21 @@ function StatusRow({ icon, title, value, - description, tone, }: { icon: ReactNode; title: string; value: string; - description: string; tone: "default" | "secondary" | "outline" | "destructive"; }) { return ( -
+
{icon} -
-
-

{title}

- {value} -
-

- {description} -

+
+

{title}

+ {value}
); diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 613abec..7c04260 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -42,17 +42,29 @@ function DialogContent({ className, children, showCloseButton = true, + nestedOverlay = false, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; + /** Dims a parent dialog; Base UI suppresses inner backdrops for nested dialogs. */ + nestedOverlay?: boolean; }) { return ( - + {nestedOverlay ? ( +