diff --git a/e2e/admin-home.spec.ts b/e2e/admin-home.spec.ts index d2e1de5..5842e5d 100644 --- a/e2e/admin-home.spec.ts +++ b/e2e/admin-home.spec.ts @@ -243,17 +243,17 @@ test.describe('mobile', () => { await advancedToggle(page).click() await page.getByRole('button', { name: 'Device', exact: true }).click() - // Network and Security are collapsible, closed by default; open both. + // Network and After a power cut are collapsible, closed by default; open both. await page.getByText('Network', { exact: true }).click() await expect(page.getByLabel('WiFi SSID')).toBeVisible() await expect(page.getByLabel('WiFi password')).toBeVisible() await expect(page.getByRole('button', { name: 'Save to device', exact: true })).toBeVisible() - await page.getByText('Security', { exact: true }).click() - await expect(page.getByRole('heading', { name: 'Boot PIN', exact: true })).toBeVisible() - const unattendedWarning = page.getByText(/A boot PIN is typed over the USB cable/) + await page.getByText('After a power cut', { exact: true }).click() + await page.locator('summary', { hasText: 'Boot PIN' }).click() + const unattendedWarning = page.getByText(/a signer with a boot PIN stays locked/) await expect(unattendedWarning).toBeVisible() - await expect(unattendedWarning).toContainText('signing and remote management cannot resume') + await expect(unattendedWarning).toContainText('Signing and remote management stop') await expect(unattendedWarning).toContainText('use a vault key with phone unlock instead') const overflow = await page.evaluate( diff --git a/src/app.css b/src/app.css index 818920b..09744ad 100644 --- a/src/app.css +++ b/src/app.css @@ -145,6 +145,7 @@ body { } .field-input::placeholder { color: #555; } .field-input:focus { outline: none; border-color: var(--green-dim); } +.field-input:focus-visible { outline: 2px solid var(--green); outline-offset: 1px; } .field-input:disabled { opacity: 0.4; } select.field-input { cursor: pointer; } .field-hint { font-size: 0.8rem; color: var(--text-dim); line-height: 1.5; } diff --git a/src/components/DevicePanel.svelte b/src/components/DevicePanel.svelte index 256ae51..8d12be9 100644 --- a/src/components/DevicePanel.svelte +++ b/src/components/DevicePanel.svelte @@ -1,14 +1,17 @@
- {#if pairingBackup.needsBackup} -
-

Pairing backup required

-

- {#if pairingBackup.lastExportAt} - App pairings changed after this browser's recorded encrypted backup ({backupTime(pairingBackup.lastExportAt)}). - {:else} - App pairings changed and this browser has not recorded a completed encrypted pairing backup. - {/if} - A reset or reflash would make the affected apps pair again. {#if overUsb}Export a fresh backup below and keep its passphrase separately.{:else}Connect this signer by USB to export a fresh backup; the signer will ask for a physical button confirmation.{/if} -

-
- {/if} + - -
-

Your signer

-
- {#each summaryRows as row (row.id)} -
- + {#if attention.length} +
+ {#each attention as row, i (row.id)} +
+
- {row.label} - - {row.text} + {row.label} +
+

{row.text}

{#if row.actionLabel} - + {/if}
{/each} -
-
- - -
- Firmware{updateInfo?.upgrade ? 'Update available' : 'Up to date'} - -
+ + {/if} - - {#if overUsb || device.mode === 'relay'} -
- After a power cut{powerCutStateWord} -

How this signer recovers its keys after a restart. Pick the option that suits - where it lives.

-
- {#each MODES as mode (mode.id)} -
-
- {mode.name} - {#if currentMode === mode.id}current{/if} -
-

After a power cut: {mode.after}

-

If someone takes the board: {mode.taken}

- {#if currentMode !== mode.id && chosenMode !== mode.id} - - {/if} - {#if chosenMode === mode.id && currentMode !== mode.id} -
- {#if mode.id === 'phone'} - {#if currentMode === null} -

Phone unlock needs encryption on. Turn on Encrypt at rest - (Security{overUsb ? ', below' : ', over the USB cable'}), then add a phone - below over the cable. Add two if you can, so one lost abroad does not leave - the signer locked.

- {:else} -

Add a phone below{overUsb ? '' : ', with the signer on the USB cable'}. Encryption is already on.

- {/if} - {:else if mode.id === 'sapwood'} - {#if (effectivePhoneCount ?? 0) > 0} -

Revoke each phone below. Encryption stays on.

- {:else} -

Turn on Encrypt at rest (Security{overUsb ? ', below' : ', over the USB cable'}), or set a boot PIN.

- {/if} - {:else} - {#if !overUsb} -

Turning encryption off needs the signer on the USB cable.

- {:else} - - {#if vaultStored} - - {#if vaultStatus}

{vaultStatus}

{/if} - {:else} -

This browser holds no vault key for the signer. If it has a - boot PIN, clear the PIN in Security, below.

{/if} +
+

After a power cut: {mode.after}

+

If someone takes the board: {mode.taken}

+ {#if chosenMode === mode.id && currentMode !== mode.id} +
+ {#if mode.id === 'phone'} + {#if currentMode === null} +

Phone unlock needs encryption on. + {#if overUsb}Turn it on under Vault key, below,{:else}Connect by USB to turn encryption on, then{/if} + then add a phone over the cable. Add two if you can, so losing one does + not leave the signer locked.

+ {:else} +

Add a phone above{overUsb ? '' : ', with the signer on the USB cable'}. Encryption is already on.

+ {/if} + {:else if mode.id === 'sapwood'} + {#if (effectivePhoneCount ?? 0) > 0} +

Revoke each phone above. Encryption stays on.

+ {:else} +

Turn on encryption under Vault key, below, or set a boot PIN.

+ {/if} + {:else} + {#if !overUsb} +

Turning encryption off needs the signer on the USB cable.

+ {:else} + + {#if vaultStored} + + {#if vaultStatus}

{vaultStatus}

{/if} + {:else} +

This browser holds no vault key for the signer. If it + has a boot PIN, clear the PIN below.

+ {/if} + {/if} + {/if} + +
{/if} +
+ {/each} +
+ {#if currentMode === null} + {#if atRestUnrecognised} +

Your signer reported a setting this version of Sapwood doesn't + know. Update Sapwood.

+ {:else if overUsb} +

This browser holds no vault key for the signer, and the signer + does not say whether it has a boot PIN. With neither, it runs without encryption.

+ {:else} +

Over WiFi, Sapwood can tell only from the phones listed to the + side. Connect by USB to see whether this browser holds the signer's vault key.

+ {/if} + {/if} +
+ Why is there no automatic phone unlock? +

Anyone holding the board can make it ask for its key, and a phone + that answered by itself would hand it over: no safer than no encryption, while + looking safer.

+
+ + {#if !overUsb} +

Vault key

+

The boot PIN and vault key are changed over USB. Plug the signer into + this computer and connect by cable.

+ {:else} +

Vault key

+

Encrypts the keys stored on the signer. The key lives in this browser, + never on the signer, so a stolen signer yields only ciphertext.

+ {#if vaultLocked} +

This signer is locked. Unlock it above before changing + encryption.

+ {:else if !vaultDeviceKey} +

Add an identity to the signer first.

+ {:else if vaultEscrowKey} +
+

Store this vault key somewhere safe outside this browser, + such as a password manager or on paper, before you seal the signer. Without it, + a sealed signer cannot be unlocked.

+
{vaultEscrowKey}
+
+ + +
+ +
+ + +
+
+ {:else if vaultStored} +

This browser holds a vault key for this signer.

+
+

Store this somewhere safe off-site (e.g. password manager). It + unlocks your signer if this browser's storage is lost.

+ {#if vaultShowKey} +
{vaultStored}
+ {/if} +
+ + + +
+
+ + {:else} + +
+ Restore a vault key saved elsewhere +

Paste a vault key you escrowed from another browser so this one + can unlock the signer too.

+
+ + +
+
+ {/if} + {#if vaultStatus}

{vaultStatus}

{/if} + +
+ Boot PIN +

After any restart, a signer with a boot PIN stays locked until + someone types the PIN over the USB cable. Signing and remote management stop, and + it refuses remote network changes. For a signer you leave running elsewhere, use a + vault key with phone unlock instead.

+
+ +
+
+ { clearPinAck = false; clearPinConfirming = false }} + placeholder="4 to 8 digits" + maxlength="8" + disabled={pinPending} + /> + +
+ + +
+

4 to 8 digits

+
+ {#if clearPinConfirming} + +
+ + +
{/if} - -
+ {#if pinStatus}

{pinStatus}

{/if} + {/if} - {/each} - - {#if currentMode === null} - {#if atRestUnrecognised} -

Your signer reported a setting this version of Sapwood doesn't know. - Update Sapwood.

- {:else if overUsb} -

This browser holds no vault key for the signer, and the signer does not - say whether it has a boot PIN. With neither, it runs without encryption.

- {:else} -

Over WiFi, Sapwood can tell only from the phones listed below. Connect - by USB to see whether this browser holds the signer's vault key.

- {/if} - {/if} -
- Why is there no automatic phone unlock? -

Anyone holding the board can make it ask for its key, and a phone that - answered by itself would hand it over: no safer than no encryption, while looking safer.

+
-
- -
+ {/if} + + +
+ +

Firmware

+ {firmwareStateWord} +
+
- {/if} - -
- Security{securityStateWord} - {#if !overUsb} -

The boot PIN, bridge secret and encryption at rest are changed over USB. - Plug the signer into this computer and connect by cable.

+ + {#if overUsb} +
+ +

Backup

+ {backupStateWord} +
+ +
{:else} -

Boot PIN

-

Locks the device at boot. It must be unlocked before it signs anything. - The device asks for its button to confirm.

-

A boot PIN is typed over the USB cable, so after any reboot or power cut - a signer in another location stays locked until someone reaches it: signing and remote - management cannot resume, and the signer refuses remote network activation in this mode. - For an unattended signer, use a vault key with phone unlock instead (After a power cut, - above).

-
-
- { clearPinAck = false; clearPinConfirming = false }} - placeholder="4–8 digits (empty to clear)" - maxlength="8" - disabled={pinPending} - /> - -
- {#if pinValue || !clearPinConfirming} - - {/if} -
- {#if !pinValue && clearPinConfirming} - -
- - -
- {/if} - {#if pinStatus}

{pinStatus}

{/if} - -

Encrypt at rest

-

Encrypts the signer's stored keys with a vault key held by this browser, never - by the device: a stolen device yields only ciphertext.

- {#if vaultLocked} -

This signer is locked. Unlock it from the banner on Home before - changing encryption.

- {:else if !vaultDeviceKey} -

Add an identity to the signer first.

- {:else if vaultEscrowKey} -
-

Your vault key: store it somewhere safe off this browser - (password manager, printed) before the signer is sealed. Without it, - a sealed signer cannot be unlocked.

-
{vaultEscrowKey}
-
- - -
- -
- - -
-
- {:else if vaultStored} -

This browser holds a vault key for this signer.

-
-

Store this somewhere safe off-site (e.g. password manager). It - unlocks your signer if this browser's storage is lost.

- {#if vaultShowKey} -
{vaultStored}
- {/if} -
- - - -
-
- - {:else} - -
- Restore a vault key saved elsewhere -

Paste a vault key you escrowed from another browser so this one can - unlock the signer too.

-
- - -
-
- {/if} - {#if vaultStatus}

{vaultStatus}

{/if} - -
- Advanced: bridge secret -

Shared secret for bridge authentication (device-decrypts mode). Needs the - button; cannot be set while a bridge session is active.

-
-
- - -
- -
- {#if secretStatus}

{secretStatus}

{/if} +
+ +

Backup

+ {backupStateWord} +
+

App pairings and the bridge secret are backed up over USB. The signer asks + for a button press.

{/if} -
- - {#if overUsb} -
- Backup{backupStateWord} - + +
+ +

Network

+ {networkStateWord} +
+ + {#if overUsb} +
+ Advanced: bridge secret +

Shared secret for bridge authentication (device-decrypts mode). Needs the + button; cannot be set while a bridge session is active.

+
+ +
+
+ + +
+ +
+

64 hexadecimal characters

+
+ {#if secretStatus}

{secretStatus}

{/if} +
+ {/if}
- {/if} - - -
- Network{networkStateWord} - -
- -
- Display and light{displayStateWord} -

Screen orientation and the activity light's log detail.

- {#if device.mode === 'relay' && typeof device.relayStatus?.log_quiet === 'boolean'} -
- Activity light and log detail -
- - + +
+ +

Display and light

+ {displayStateWord} +
+

Screen orientation and the activity light's log detail.

+ {#if device.mode === 'relay' && typeof device.relayStatus?.log_quiet === 'boolean'} +
+ Activity light and log detail + setLogQuiet(!detailed)} + /> +

The signer's blue activity light flashes with its log output. Quiet keeps + warnings only, so the light stays dark in normal use.

-

The signer's blue activity light flashes with its log output. Quiet keeps - warnings only, so the light stays dark in normal use.

-
- {/if} - {#if screenFlip !== null} -
- Screen orientation -
- - + {/if} + {#if screenFlip !== null} +
+ Screen orientation + turnScreen(!upright)} + /> +

Turn the picture round if the signer sits the other way up, for the + other hand or a case. The button labels on its cards move with it. On the signer itself: hold its + button on the DEVICE page.

-

Turn the picture round if the signer sits the other way up, for the - other hand or a case. The button labels on its cards move with it. On the signer itself: hold its - button on the DEVICE page.

-
- {/if} -
+ {/if} +
- -
- Diagnostics{diagnosticsStateWord} -

Live numbers from the signer: connection, memory and storage.

- - - - - - {#if typeof health.uptime_s === 'number'} - + +
+ +

Diagnostics

+ {diagnosticsStateWord} +
+

Live numbers from the signer.

+
Connected over{modeLabel()}
Address{device.portInfo || '--'}
Identities{device.masters.filter((m) => !m.persona).length}
Apps{device.slots.length}
Signer up{formatUptime(health.uptime_s)}
+ + + + + {#if typeof health.uptime_s === 'number'} + + {/if} + {#if lastReset} + + {/if} + {#if typeof freeHeap === 'number' && typeof largestBlock === 'number'} + + + + + {/if} + {#if typeof maxSignBytes === 'number'} + + {/if} + {#if storage} + + + + + {/if} +
Connected over{modeLabel()}
Address{device.portInfo || '--'}
Identities{device.masters.filter((m) => !m.persona).length}
Apps{device.slots.length}
Signer up{formatUptime(health.uptime_s)}
Last restart{recoveryReason ? 'self-recovery restart' : lastReset.text}
Free memory + {kb(freeHeap)}{#if fragmented} · fragmented (largest block {kb(largestBlock)}){/if} +
Max signed message + {formatBytes(maxSignBytes)}{#if typeof maxSignBytesObject === 'number' && maxSignBytesObject > maxSignBytes} + · {formatBytes(maxSignBytesObject)} for apps that ask for the compact reply{/if} +
Identity and app storage + + {storage.label} +
+ {#if storage?.state === 'warn'} +

The signer's storage is filling up. Identities, personas, app pairings + and settings share it. Removing an unused persona or app pairing frees space.

{/if} - {#if lastReset} - Last restart{recoveryReason ? 'self-recovery restart' : lastReset.text} + {#if storage?.state === 'full'} +

The signer's storage is nearly full. It will refuse new personas before + app pairings stop working, so remove an unused persona or app pairing now.

{/if} - {#if typeof freeHeap === 'number' && typeof largestBlock === 'number'} - - Free memory - - {kb(freeHeap)}{#if fragmented} · fragmented (largest block {kb(largestBlock)}){/if} - - + {#if lastReset?.crash} +

The signer's last restart was not planned.{#if health.crashed_during} It crashed while + handling {health.crashed_during}.{/if} If this repeats, note the pattern; the request log + below restarts empty each boot.

{/if} - {#if typeof maxSignBytes === 'number'} - Max signed message - {formatBytes(maxSignBytes)}{#if typeof maxSignBytesObject === 'number' && maxSignBytesObject > maxSignBytes} - · {formatBytes(maxSignBytesObject)} for apps that ask for the compact reply{/if} - + {#if fragmented} +
+ Why is the memory fragmented? +

The signer's largest free block is small relative to total free. + This can happen after a burst of decryptions; it clears on the next restart. Newer firmware + frees the TLS buffers between messages to avoid it.

+
{/if} - {#if storage} - - Identity & app storage - - - {storage.label} - - + {#if trimmed} +

The signer trimmed this status to its vital fields because its memory + was too fragmented to send the full report. The request log paused this poll instead of the signer + crashing, and resumes once the memory recovers.

{/if} - - {#if storage?.state === 'warn'} -

The signer's storage is filling up. Identities, personas, app pairings - and settings share it. Removing an unused persona or app pairing frees space.

- {/if} - {#if storage?.state === 'full'} -

The signer's storage is nearly full. It will refuse new personas before - app pairings stop working, so remove an unused persona or app pairing now.

- {/if} - {#if lastReset?.crash} -

The signer's last restart was not planned.{#if health.crashed_during} It crashed while - handling {health.crashed_during}.{/if} If this repeats, note the pattern; the request log - below restarts empty each boot.

- {/if} - {#if fragmented} -
- Why is the memory fragmented? -

The signer's largest free block is small relative to total free. - This can happen after a burst of decryptions; it clears on the next restart. Newer firmware - frees the TLS buffers between messages to avoid it.

-
- {/if} - {#if trimmed} -

The signer trimmed this status to its vital fields because its memory - was too fragmented to send the full report. The request log paused this poll instead of the signer - crashing, and resumes once the memory recovers.

- {/if} - {#if recoveryReason} -
- Why did the signer restart itself? -

Its relay service was unusable for several minutes - ({recoveryReason}), usually memory too fragmented to place TLS or publish - buffers. It now recovers on its own instead of staying unreachable until a power-cycle; if - this repeats often, the memory readings above tell the story.

+ {#if recoveryReason} +
+ Why did the signer restart itself? +

Its relay service was unusable for several minutes + ({recoveryReason}), usually memory too fragmented to place TLS or publish + buffers. It now recovers on its own instead of staying unreachable until a power-cycle; if + this repeats often, the memory readings above tell the story.

+
+ {/if} +
+ + + {#if overBridge && device.bridgeInfo} +
+ +

Bridge

+ {bridgeStateWord} +
+ + + + + + + +
Mode{device.bridgeInfo.mode}
Uptime{formatUptime(device.bridgeInfo.uptime_secs as number)}
Relays + {#each (device.bridgeInfo.relays as string[]) as relay} +
{relay}
+ {/each} +
+

The bridge holds the USB port. Stop it to connect directly over USB + (then reload Sapwood and connect by cable).

+
+ + +
{/if} -
- - - {#if overBridge && device.bridgeInfo} -
- Bridge{bridgeStateWord} - - - - - - - -
Mode{device.bridgeInfo.mode}
Uptime{formatUptime(device.bridgeInfo.uptime_secs as number)}
Relays - {#each (device.bridgeInfo.relays as string[]) as relay} -
{relay}
- {/each} -
-

The bridge holds the USB port. Stop it to connect directly over USB - (then reload Sapwood and connect by cable).

-
- - -
-
- {/if} +

Flash another board: install or re-flash firmware in the guided @@ -1155,50 +1216,73 @@ .log-quiet { margin-top: 0.9rem; } .lq-label { display: block; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.4rem; } - .lq-buttons { display: flex; gap: 0.5rem; margin-bottom: 0.4rem; } - .lq-on { border-color: var(--green-dim); color: var(--green); background: #08130d; } .device-panel { - display: flex; flex-direction: column; gap: 1.75rem; + display: flex; flex-direction: column; gap: 1.5rem; /* Room above the docked mobile tab bar so the last section (Danger zone) is never hidden behind it. */ padding-bottom: 2rem; } - .pairing-backup-warning { border-color: var(--amber); background: #1a1508; } - .pairing-backup-warning .section-title { color: var(--amber); } - - /* "Your signer": bold, spaced-out rows, worst concern first. The heading - reads as the headline: same weight as a section summary, a touch bigger. */ - .your-signer { display: flex; flex-direction: column; gap: 0.25rem; } - .your-signer > .section-title { font-size: 1.2rem; font-weight: 700; color: #fff; margin-bottom: 1rem; } - .summary-rows { display: flex; flex-direction: column; gap: 1rem; } - .summary-row { - display: flex; align-items: center; gap: 0.75rem; - padding: 0.9rem 1rem; - background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + + /* Needs attention: 0 to n cards, worst first, spaced not boxed-within-boxed. */ + .attention-block { display: flex; flex-direction: column; gap: 0.75rem; } + .attention-card { + display: flex; flex-direction: column; gap: 0.35rem; align-items: flex-start; + border-left-width: 3px; border-left-style: solid; } - .summary-lead { flex: 0 0 13.5rem; display: flex; align-items: center; gap: 0.6rem; min-width: 0; } - .dot { - flex: none; width: 0.7rem; height: 0.7rem; border-radius: 50%; + .attention-problem { border-left-color: var(--red); } + .attention-attention { border-left-color: var(--amber); } + .attention-lead { display: flex; align-items: center; gap: 0.5rem; } + .attention-label { font-size: 1rem; font-weight: 600; color: var(--text); } + .attention-text { margin: 0; font-size: 0.85rem; color: var(--text-dim); } + .attention-action { margin-top: 0.2rem; } + + @media (min-width: 1024px) { + .attention-card { flex-direction: row; align-items: center; justify-content: space-between; gap: 1rem; } + .attention-lead { flex: 0 0 auto; } + .attention-text { flex: 1 1 auto; } + .attention-action { margin-top: 0; margin-left: auto; } } + + .dot { flex: none; width: 0.6rem; height: 0.6rem; border-radius: 50%; } .dot-ok { background: var(--green); } - .dot-unknown { background: var(--text-muted); } + .dot-unknown { background: transparent; border: 2px solid var(--text-muted); } .dot-attention { background: var(--amber); } .dot-problem { background: var(--red); } - .summary-label { font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .summary-text { flex: 1 1 auto; min-width: 0; font-size: 1rem; font-weight: 600; color: var(--text); } - .summary-action { flex: none; margin-left: auto; } - - /* Phone width: dot+label on their own line, then the state text, then a - full-width button. The dot never sits alone on a line. */ - @media (max-width: 480px) { - .summary-row { flex-direction: column; align-items: stretch; gap: 0.4rem; } - .summary-lead { flex: none; } - .summary-text { flex: none; } - .summary-action { margin-left: 0; width: 100%; } + + /* Grouped section list: one border, rows split by dividers. */ + .section-group { + border: 1px solid var(--border); border-radius: 6px; + } + .section-row { padding: 0 1rem; } + .section-row + .section-row { border-top: 1px solid var(--border); } + .section-row > summary { + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: 0.3rem 1rem; padding: 0.9rem 0; cursor: pointer; list-style: none; + } + .section-row > summary::-webkit-details-marker { display: none; } + .section-row-title { + margin: 0; font-size: 1rem; font-weight: 600; color: var(--text); display: inline-flex; align-items: center; + white-space: nowrap; + } + .section-row-title::before { + content: '▸'; display: inline-block; margin-right: 0.6rem; color: var(--text-muted); + transition: transform 0.15s; + } + .section-row[open] > summary .section-row-title::before { transform: rotate(90deg); } + .section-row-state { + /* A long state drops under the title on a phone rather than squeezing it. */ + font-size: 0.85rem; color: var(--text-dim); margin-left: auto; text-align: right; + display: inline-flex; align-items: center; gap: 0.4rem; + } + .section-row > :not(summary) { padding: 0 0 1.25rem; } + .section-row :global(.hint), + .section-row :global(.hint-sm) { max-width: 68ch; } + + @media (prefers-reduced-motion: reduce) { + .section-row-title::before { transition: none; } } - /* Collapsible sections: bold, roomy summaries with a state word docked right. */ .device-section { border: 1px solid var(--border); border-radius: 8px; padding: 0 1.1rem; @@ -1210,18 +1294,13 @@ .device-section > summary::-webkit-details-marker { display: none; } .device-section--danger { border-color: #442222; } .summary-title { font-size: 1.05rem; font-weight: 700; color: #fff; } - .summary-title::before { - content: '▸'; display: inline-block; margin-right: 0.6rem; color: var(--text-muted); - transition: transform 0.15s; - } - .device-section[open] > summary .summary-title::before { transform: rotate(90deg); } - .summary-state { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; } .device-section > :not(summary) { padding-bottom: 1.1rem; } .advanced-block { margin-top: 1.1rem; } .flash-line { margin: 0; } .sub-title { font-size: 0.9rem; font-weight: 600; color: var(--text); margin: 1.1rem 0 0.4rem; } .sub-title:first-of-type { margin-top: 0; } + .sub-title:focus { outline: 2px solid var(--green); outline-offset: 2px; } .inline-form { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .inline-form .field-input { font-size: 0.85rem; padding: 0.45rem 0.7rem; } @@ -1229,17 +1308,24 @@ .bridge-hint { margin-top: 0.8rem; } - .modes { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 0.75rem; margin: 0.8rem 0; } - .mode { display: flex; flex-direction: column; gap: 0.4rem; align-items: flex-start; } - .mode-current { border-color: var(--green-dim); } - .mode-head { display: flex; gap: 0.5rem; align-items: center; } + .power-cut-grid { display: flex; flex-direction: column; gap: 1.5rem; } + .power-cut-left, .power-cut-right { min-width: 0; } + @media (min-width: 1024px) { + .power-cut-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2rem; } + } + + .mode-list { display: flex; flex-direction: column; margin: 0.6rem 0; } + .mode-row { padding: 0.7rem 0; border-top: 1px solid var(--border); } + .mode-row:first-child { border-top: none; } + .mode-row-head { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; } .mode-name { font-weight: 600; color: var(--text); } + .mode-switch { margin-left: auto; } .mode-label { color: var(--text); } - .mode-steps { display: flex; flex-direction: column; gap: 0.5rem; align-items: flex-start; margin-top: 0.3rem; } + .mode-steps { display: flex; flex-direction: column; gap: 0.5rem; align-items: flex-start; margin-top: 0.5rem; } .ack { display: flex; gap: 0.5rem; align-items: flex-start; margin-top: 0.4rem; } .ack input { margin-top: 0.2rem; flex: none; } - .phones { margin-top: 1.2rem; } + .boot-pin { margin-top: 1.1rem; } .vault-escrow { display: flex; flex-direction: column; gap: 0.6rem; margin: 0.6rem 0 0.9rem; @@ -1262,7 +1348,18 @@ @media (max-width: 640px) { .danger-row { flex-wrap: wrap; } .inline-form .pw-wrap { width: 100%; } - .summary-row { padding: 0.8rem 0.85rem; } + .section-row { padding: 0 0.85rem; } .device-section { padding: 0 0.85rem; } } + + /* Touch targets: scoped to this tab only (Q4). */ + @media (any-pointer: coarse), (max-width: 640px) { + .device-panel :global(.btn) { min-height: 44px; } + .device-panel :global(button.btn-sm) { min-height: 44px; } + /* Padding, not flex: flex would drop the disclosure marker. */ + .device-panel :global(summary) { min-height: 44px; box-sizing: border-box; padding-block: 0.7rem; } + .device-panel :global(button), + .device-panel :global(summary) { touch-action: manipulation; } + .device-panel :global(.btn:active:not(:disabled)) { transform: translateY(1px); } + } diff --git a/src/components/DevicePanel.test.ts b/src/components/DevicePanel.test.ts index 824cea8..988edcd 100644 --- a/src/components/DevicePanel.test.ts +++ b/src/components/DevicePanel.test.ts @@ -21,6 +21,7 @@ vi.mock('../lib/device.svelte.js', async () => { const state: Record = { connected: true, mode: 'relay', error: null, masters: [], slots: [], relayStatus: null, bridgeAuthed: false, connectionGeneration: 0, + vaultRelayTarget: null, vaultUnlockRequest: null, vaultReconnect: null, } const device = new Proxy(state, { get(target, property, receiver) { @@ -45,6 +46,9 @@ vi.mock('../lib/device.svelte.js', async () => { usbDisplayFlip: vi.fn().mockResolvedValue(null), setDisplayFlip: vi.fn(), getFirmwareVersion: vi.fn().mockResolvedValue(null), + refreshMasters: vi.fn().mockResolvedValue(undefined), + sendVaultKeyOverRelay: vi.fn().mockResolvedValue(undefined), + vaultRelayDevicePub: vi.fn(() => ''), listUnlockPhones: vi.fn(), revokeUnlockPhone: vi.fn(), setAnnounceOperator: vi.fn(), @@ -111,6 +115,7 @@ describe('DevicePanel: the session override on a fresh firmware report', () => { // Without the fix, the session's own "PIN set" snapshot would win forever // and this would stay "Waits for Sapwood" even once FIRMWARE_INFO // disagreed; the retired override lets the fresh report show through. - await waitFor(() => expect(screen.getByText('Not encrypted: anyone holding it can read the keys')).toBeTruthy()) + await waitFor(() => expect(screen.getAllByText('Not encrypted').length).toBeGreaterThan(0)) + expect(screen.getByText('Anyone holding it can read the keys.')).toBeTruthy() }) }) diff --git a/src/components/PhoneHandoff.svelte b/src/components/PhoneHandoff.svelte index 93e9cdb..3a928ea 100644 --- a/src/components/PhoneHandoff.svelte +++ b/src/components/PhoneHandoff.svelte @@ -338,8 +338,9 @@ .handoff { margin-top: 1.5rem; } .handoff .section-title { font-size: 0.95rem; color: var(--green); } .field { margin-bottom: 0.9rem; } - .qr { width: 184px; padding: 12px; background: #fff; border-radius: 6px; margin-bottom: 0.9rem; } + .qr { width: 220px; padding: 12px; background: #fff; border-radius: 6px; margin-bottom: 0.9rem; } .qr :global(svg) { display: block; width: 100%; height: auto; } + @media (max-height: 500px) { .qr { width: 180px; } } .handoff-actions { display: flex; gap: 0.6rem; flex-wrap: wrap; } .plain-link { display: block; margin: 0.9rem 0 0; padding: 0; width: 100%; diff --git a/src/components/TogglePair.svelte b/src/components/TogglePair.svelte new file mode 100644 index 0000000..4284d55 --- /dev/null +++ b/src/components/TogglePair.svelte @@ -0,0 +1,50 @@ + + +

+ + +
+ + diff --git a/src/components/UnlockPhones.svelte b/src/components/UnlockPhones.svelte index 53892d0..f80d876 100644 --- a/src/components/UnlockPhones.svelte +++ b/src/components/UnlockPhones.svelte @@ -3,7 +3,7 @@ // signer after a restart with a tap. Add one by scanning the code Cambium // shows, list them, revoke one. No secret passes through Sapwood: the board // seals each phone's unlock secret to a key only that phone holds. - import { onDestroy, untrack } from 'svelte' + import { onDestroy, tick, untrack } from 'svelte' import { SimplePool } from 'nostr-tools/pool' import { encodeQR } from '@paulmillr/qr' import { @@ -22,6 +22,8 @@ } from '../lib/enrol-invite.js' import ConfirmButton from './ConfirmButton.svelte' import QrScanner from './QrScanner.svelte' + import WordPairs from './WordPairs.svelte' + import TogglePair from './TogglePair.svelte' /** Fallback relays for a fresh invite: the ones Sapwood already uses for * this signer over the relay, its USB-reported WiFi relays, or failing @@ -164,6 +166,9 @@ let working = $state(null) let result = $state(null) let addError = $state(null) + /** Which of the two "invite-aborted" stories to tell: someone else answered, + * or nobody did. */ + let abortReason = $state<'two-phones' | 'no-relays' | null>(null) /** Set once the board has been asked: its enrolment key is then spent. */ let codeSpent = $state(false) /** A record the board may have kept with nobody holding its secret: the @@ -212,7 +217,8 @@ code = inviteState.code step = 'confirm' } else if (inviteState.status === 'aborted') { - addError = 'Two phones answered this code. Someone else may have seen it. Nothing was added; start again.' + addError = 'Someone else may have seen the code. Nothing was added. Cancel on your phone, then make a new code.' + abortReason = 'two-phones' stopInvite() step = 'invite-aborted' } @@ -225,6 +231,7 @@ code = null result = null addError = null + abortReason = null codeSpent = false scanSpent = false orphanId = null @@ -243,6 +250,7 @@ zeroInviteSecret(made) stopInvite() addError = 'None of the relays answered, so the phone could not reply. Check this computer\'s connection and try again.' + abortReason = 'no-relays' step = 'invite-aborted' return } @@ -274,6 +282,7 @@ code = null result = null addError = null + abortReason = null codeSpent = false scanSpent = false orphanId = null @@ -353,8 +362,7 @@ enrol: async (enrolPubkey) => { codeSpent = true if (code) markCodeSpent(code) - return enrolUnlockPhone(enrolPubkey, label, - `Check the signer: it shows ADD PHONE and five words, two at a time over about 12 seconds before it will accept a hold. Compare the five words with your phone, then hold its button.`) + return enrolUnlockPhone(enrolPubkey, label, 'Signer: compare the five words, then hold its button.') }, onBoard: () => { working = isRelay @@ -378,13 +386,55 @@ if (codeSpent) void load(true) } } + + // --- 7.2 Add a phone: the constant enrolment card --- + // The heading and live region exist from the first render of the card (any + // step but 'idle'). Every step change sets both and moves focus to the + // heading, so the compare-and-confirm bench flow announces itself to + // assistive tech without the owner hunting for what changed. + const confirmWords = $derived(code ? requestWords(code.enrolPubkey) : []) + + const phase = $derived( + step === 'invite' ? (invite ? 'invite-ready' : 'invite-opening') : step, + ) + + let cardHeadingEl = $state(null) + let cardHeadingText = $state('') + let liveText = $state('') + + $effect(() => { + const p = phase + const label = code?.label ?? 'The phone' + if (p === 'invite-opening') { cardHeadingText = 'Add a phone'; liveText = 'Opening relays.' } + else if (p === 'invite-ready') { cardHeadingText = 'Scan this with Cambium'; liveText = 'Code ready. It expires in 10 minutes.' } + else if (p === 'invite-expired') { cardHeadingText = 'Code expired'; liveText = 'Code expired.' } + else if (p === 'invite-aborted') { + cardHeadingText = abortReason === 'no-relays' ? 'Could not reach the relays' : 'Two phones answered' + liveText = abortReason === 'no-relays' ? 'Could not reach the relays.' : 'Stopped: two phones answered.' + } else if (p === 'scan' || p === 'paste') { cardHeadingText = 'Add a phone'; liveText = '' } + else if (p === 'confirm') { cardHeadingText = `${label} wants to unlock this signer`; liveText = `${label} answered. Compare the five words.` } + else if (p === 'working') { cardHeadingText = 'Check the signer'; liveText = 'Sent to the signer. Hold its button when the words match.' } + else if (p === 'done') { cardHeadingText = `${label} added`; liveText = result ? `${label} added. Check code ${result.checkCode}.` : '' } + else if (p === 'orphan') { cardHeadingText = 'A stray record may be left'; liveText = '' } + else if (p === 'failed') { cardHeadingText = 'Not added'; liveText = '' } + if (p !== 'idle') void tick().then(() => cardHeadingEl?.focus()) + }) + + // Announce the countdown only at its two milestones and at expiry (the + // heading effect above covers expiry): a live region that spoke every + // second would be unusable. + $effect(() => { + const s = inviteSecondsLeft + if (phase !== 'invite-ready') return + if (s === 300) liveText = 'Five minutes left to scan the code.' + else if (s === 60) liveText = 'One minute left to scan the code.' + })

Phones that can unlock

-

A phone running Cambium can unlock this signer after a power cut: it shows - a notification, and one tap plus the phone's screen lock sends the unlock. Nothing on the - wire names the phone. A lost phone is revoked here without changing anything else.

+

A phone running Cambium can unlock this signer after a power cut. Nothing on the + wire names the phone. Revoking a lost phone changes nothing else.

{#if support === 'checking'}

Asking the signer…

@@ -421,16 +471,17 @@ {/each} -

{list.phones.length} of {list.max}.{#if list.phones.length === 1} +

{list.phones.length} of {list.max} phones.{#if list.phones.length === 1} Add a second device (a spare, or a family member's phone) so losing this one abroad does not - leave the signer locked.{/if}

+ leave the signer locked.{:else if list.phones.length >= list.max} + This signer holds {list.max} phones, its limit. Revoke one to add another.{/if}

{/if} {#if revokeStatus}

{revokeStatus}

{/if} {#if step === 'idle'} {#if canAdd}
-
@@ -447,6 +498,134 @@ {/if} {/if} + {#if step !== 'idle'} +
+

{cardHeadingText}

+

{liveText}

+ + {#if step === 'invite'} + {#if !invite} +

Opening relays…

+ {:else} +
+
{@html inviteQr}
+
+
    +
  1. Open Cambium on the phone.
  2. +
  3. Open this signer's screen.
  4. +
  5. Tap Set up phone unlock, then Scan Sapwood's code.
  6. +
+

Expires in {formatCountdown(inviteSecondsLeft)}

+ +
+
+ {/if} + {:else if step === 'invite-expired'} +

Codes last 10 minutes. Cancel on the phone, then make a new code.

+
+ + +
+ {:else if step === 'invite-aborted'} +

{addError}

+
+ + +
+ {:else if step === 'scan' || step === 'paste'} +

On the phone, open Cambium and tap “Set up phone unlock” under this + signer. It shows a code; nothing in it is secret. Its “Copy code” button gives the text + to paste here.

+ {#if step === 'scan'} + { step = 'paste' }} /> + {#if scanSpent} +

That code was already used here. Start again on the phone for a new one.

+ {/if} + {:else} + + {#if pasted.trim() && !pastedCode} +

That is not a phone-unlock code from Cambium.

+ {:else if pastedSpent} +

This code was already used here. Start again on the phone for a new one.

+ {/if} +
+ + +
+ {/if} + + {:else if step === 'confirm' && code} +

Check Cambium shows these five words, in this order.

+ +

Next, the signer shows the same words, two at a time. Hold its button + only if all three screens match.

+
+ + +
+
+ Why compare with the phone, not only this page? +

Whoever relayed the request could have swapped the words shown here. + The phone and the signer cannot both be swapped, so their match is the check that + counts. The check code afterwards only confirms the phone received the hand-off.

+ + + +
Phone{code.label}
Relays + {#each code.relays as relay}
{relay}
{/each} +
+
+ {:else if step === 'working'} +

Wait for ADD PHONE. It shows the words two at a time, and its button only + works once all five have shown. Hold it if they match these and the phone.

+ + {#if working}

{working}

{/if} + {:else if step === 'done' && result} +

Check code

+

{result.checkCode}

+

Cambium and the signer show the same code. If all three match, confirm + on the phone with its screen lock.

+
+ + { + const r = result + const label = code?.label ?? 'the phone' + if (r) void revoke(r.id, label) + step = 'idle' + }} + /> +
+ {:else if step === 'orphan' && orphanId !== null} +

{addError ?? `The signer never answered in time, but it may have kept a record nobody holds (record ${orphanId}): its secret never reached this browser, so it cannot reach the phone either.`}

+

Revoke it, then start again with a new code from the phone.

+
+ + +
+ {:else if step === 'failed'} +

{addError}

+
+ {#if codeSpent} + + {:else} + + {/if} + +
+ {/if} +
+ {/if} +
Sapwood's own unlock message

When it is locked, the signer also posts a message tagged with your operator @@ -454,121 +633,18 @@ can link the signer's restarts. Phone messages carry no such tag. If your phones are enough, turn it off; Sapwood can still unlock over USB. It stays on while no phone is set up, or a locked signer could only be unlocked over the cable.

-
- - -
+ {#if announceStatus}

{announceStatus}

{/if}
{/if} - - {#if step !== 'idle'} -
- {#if step === 'invite'} - {#if !invite} -

Opening relays…

- {:else} -

Scan this with Cambium

-

On the phone, open Cambium, go to this signer's screen, and tap - "Set up phone unlock", then "Scan Sapwood's code".

-
{@html inviteQr}
-

Waiting for the phone. Expires in {formatCountdown(inviteSecondsLeft)}.

- {/if} - - {:else if step === 'invite-expired'} -

Code expired, make a new one.

-
- - -
- {:else if step === 'invite-aborted'} -

{addError}

-
- - -
- {:else if step === 'scan' || step === 'paste'} -

On the phone, open Cambium and tap “Set up phone unlock” under this - signer. It shows a code; nothing in it is secret. Its “Copy code” button gives the text - to paste here.

- {#if step === 'scan'} - { step = 'paste' }} /> - {#if scanSpent} -

That code was already used here. Start again on the phone for a new one.

- {/if} - {:else} - - {#if pasted.trim() && !pastedCode} -

That is not a phone-unlock code from Cambium.

- {:else if pastedSpent} -

This code was already used here. Start again on the phone for a new one.

- {/if} -
- - -
- {/if} - - {:else if step === 'confirm' && code} - - - -
Phone{code.label}
Waiting on - {#each code.relays as relay}
{relay}
{/each} -
-

The signer shows ADD PHONE and five words, two at a time over about - 12 seconds before it will accept a hold.

-
-

Check your phone shows these same five words

-

{requestWords(code.enrolPubkey).join(' ')}

-

Compare them with your phone's own screen, and with the signer's - card, before holding the button: that is the check that matters, since whoever - relayed the request could have swapped the words shown here. A check code appears - afterwards too, but it only confirms the phone got the hand-off; it does not defend - against a swapped phone.

-
-
- - -
- {:else if step === 'working'} -

{working}

- {:else if step === 'done' && result} -

The signer added {code?.label} as record {result.id}.

-

This check code confirms the phone got it: the five words you compared - before holding the button are what defended against a swapped phone. The phone should show - the same six characters:

-

{result.checkCode}

-

If they match, confirm on the phone with its screen lock. If they do - not, someone else answered the phone first: revoke record {result.id} now and start - again.

- - {:else if step === 'orphan' && orphanId !== null} -

{addError ?? `The signer never answered in time, but it may have kept a record nobody holds (record ${orphanId}): its secret never reached this browser, so it cannot reach the phone either.`}

-

Revoke it, then start again with a new code from the phone.

-
- - -
- {:else if step === 'failed'} -

{addError}

-
- {#if codeSpent} - - {:else} - - {/if} - -
- {/if} -
- {/if}
diff --git a/src/components/UnlockPhones.test.ts b/src/components/UnlockPhones.test.ts index f86bc3b..6918ad7 100644 --- a/src/components/UnlockPhones.test.ts +++ b/src/components/UnlockPhones.test.ts @@ -174,10 +174,10 @@ describe('UnlockPhones', () => { await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: CODE } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) - await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByText('9B6 164')).toBeTruthy() expect(api.enrolUnlockPhone).toHaveBeenCalledOnce() - expect(api.enrolUnlockPhone).toHaveBeenCalledWith(P, 'Pixel 8', expect.stringMatching(/ADD PHONE/)) + expect(api.enrolUnlockPhone).toHaveBeenCalledWith(P, 'Pixel 8', expect.stringMatching(/compare the five words/i)) expect(relays.ensureRelay).toHaveBeenCalledWith('wss://relay.example', expect.anything()) const [urls, event] = relays.publish.mock.calls[0] as unknown as [string[], { kind: number; tags: string[][] }] expect(urls).toEqual(['wss://relay.example']) @@ -185,7 +185,7 @@ describe('UnlockPhones', () => { expect(event.tags).toEqual([['h', R]]) // Finished, then the same code again: refused here, not sent twice. - await fireEvent.click(screen.getByRole('button', { name: 'Finished' })) + await fireEvent.click(screen.getByRole('button', { name: 'Done' })) await fireEvent.click(await screen.findByText('Phone shows a code instead? Paste it')) await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: CODE } }) @@ -209,7 +209,7 @@ describe('UnlockPhones', () => { await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: CODE.replace(P, 'c3'.repeat(32)) } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) - await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByText(/Each code works once/)).toBeTruthy() expect(screen.getByRole('button', { name: 'Scan a new code' })).toBeTruthy() expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull() @@ -254,12 +254,13 @@ describe('UnlockPhones', () => { await fireEvent.input(screen.getByRole('textbox'), { target: { value: codeWith(p) } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) // Sapwood's own copy of the request-code words: for reference only. - expect(screen.getByText('release jar chimney acoustic depart')).toBeTruthy() - expect(screen.getByText(/Compare them with your phone's own screen/)).toBeTruthy() - await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(screen.getByText('release')).toBeTruthy() + expect(screen.getByText('depart')).toBeTruthy() + expect(screen.getByText('Check Cambium shows these five words, in this order.')).toBeTruthy() + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByText('9B6 164')).toBeTruthy() expect(api.enrolUnlockPhone).toHaveBeenCalledOnce() - expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/ADD PHONE/)) + expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/compare the five words/i)) // Sent exactly once: the hand-off is only ever published the once. const [urls, event] = relays.publish.mock.calls[0] as unknown as [string[], { kind: number; tags: string[][] }] expect(urls).toEqual(['wss://relay.example']) @@ -282,7 +283,7 @@ describe('UnlockPhones', () => { await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: codeWith(p) } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) - await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByRole('button', { name: 'Revoke record 99' })).toBeTruthy() expect(screen.getByText(/nobody holds/)).toBeTruthy() await fireEvent.click(screen.getByRole('button', { name: 'Revoke record 99' })) @@ -300,7 +301,7 @@ describe('UnlockPhones', () => { await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: codeWith(p) } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) - await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByText(/never answered in time/)).toBeTruthy() expect(screen.queryByText(/nobody holds/)).toBeNull() }) @@ -320,11 +321,11 @@ describe('UnlockPhones', () => { const p = '11'.repeat(32) opts.onevent(inviteReply(codeWith(p))) - expect(await screen.findByText('Check your phone shows these same five words')).toBeTruthy() - expect(screen.getByText('Pixel 8')).toBeTruthy() - await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(await screen.findByText('Pixel 8 wants to unlock this signer')).toBeTruthy() + expect(screen.getAllByText('Pixel 8').length).toBeGreaterThan(0) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByText('9B6 164')).toBeTruthy() - expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/ADD PHONE/)) + expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/compare the five words/i)) }) it('ignores a repeat of the identical reply', async () => { @@ -335,9 +336,9 @@ describe('UnlockPhones', () => { const p = '22'.repeat(32) opts.onevent(inviteReply(codeWith(p))) - await screen.findByText('Check your phone shows these same five words') + await screen.findByText('Pixel 8 wants to unlock this signer') opts.onevent(inviteReply(codeWith(p))) - expect(screen.getByText('Check your phone shows these same five words')).toBeTruthy() + expect(screen.getByText('Pixel 8 wants to unlock this signer')).toBeTruthy() expect(screen.queryByText(/Two phones answered/)).toBeNull() }) @@ -348,10 +349,10 @@ describe('UnlockPhones', () => { const opts = relays.subscribe.mock.calls[0][2] opts.onevent(inviteReply(codeWith('33'.repeat(32)))) - await screen.findByText('Check your phone shows these same five words') + await screen.findByText('Pixel 8 wants to unlock this signer') opts.onevent(inviteReply(codeWith('44'.repeat(32)))) - expect(await screen.findByText(/Two phones answered this code/)).toBeTruthy() + expect(await screen.findByText('Two phones answered')).toBeTruthy() expect(screen.getByRole('button', { name: 'New code' })).toBeTruthy() expect(api.enrolUnlockPhone).not.toHaveBeenCalled() }) @@ -374,7 +375,7 @@ describe('UnlockPhones', () => { await screen.findByText('Scan this with Cambium') const opts = relays.subscribe.mock.calls[0][2] opts.onevent(inviteReply(codeWith('55'.repeat(32)), 'ff'.repeat(16))) - expect(screen.queryByText('Check your phone shows these same five words')).toBeNull() + expect(screen.queryByText('Pixel 8 wants to unlock this signer')).toBeNull() }) }) }) diff --git a/src/components/VaultUnlock.svelte b/src/components/VaultUnlock.svelte index 86ff4f3..218db49 100644 --- a/src/components/VaultUnlock.svelte +++ b/src/components/VaultUnlock.svelte @@ -110,7 +110,7 @@ } const seconds = Math.round(vaultUnlockTimeoutMs(lockedSlots) / 1000) const count = lockedSlots === null ? 'each identity' : `${lockedSlots} ${lockedSlots === 1 ? 'identity' : 'identities'}` - device.awaitingButton = `Unlocking — the signer unseals ${count} with a slow key derivation on purpose, about 25 seconds each. This waits up to ${seconds} seconds; leave the cable in.` + device.awaitingButton = `Unlocking: the signer unseals ${count} with a slow key derivation on purpose, about 25 seconds each. This waits up to ${seconds} seconds; leave the cable in.` try { await serialVaultUnlock(serialTransport, keyHex, lockedSlots) } finally { @@ -134,7 +134,7 @@ async function unlockRelay(keyHex: string | undefined, remember: boolean) { busy = true status = null - device.awaitingButton = 'Sending the vault key — the signer unseals each identity with a slow key derivation on purpose, about 25 seconds each, then rejoins its relays.' + device.awaitingButton = 'Sending the vault key: the signer unseals each identity with a slow key derivation on purpose, about 25 seconds each, then rejoins its relays.' try { await sendVaultKeyOverRelay(keyHex) if (remember && keyHex && deviceKey) { @@ -158,7 +158,7 @@

Signer is locked

Its keys are encrypted at rest, so it signs nothing until it is unlocked. - It shows “Locked — Await unlock”. + It shows “Locked: Await unlock”.

{#if storedKey}
@@ -196,8 +196,8 @@