feat(extension): update devices UI for new auth model

- Show revoked devices in collapsible section with strikethrough styling
- Fetch revoked.json via new list_revoked message + router case
- Registration flow uses register_device WASM API (private keys internal)
- Display revoked_by and timestamp for each revoked entry
- Update setup wizard to use new register_device API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-02 12:29:31 -04:00
parent fb1f28161c
commit c67d484152
4 changed files with 75 additions and 23 deletions

View File

@@ -3,6 +3,13 @@
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { Device } from '../../shared/types'; import type { Device } from '../../shared/types';
interface RevokedEntry {
name: string;
public_key: string;
revoked_at: number;
revoked_by: string;
}
function relativeTime(unixSec: number): string { function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec; const diff = now - unixSec;
@@ -36,16 +43,62 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
const stored = await chrome.storage.local.get(['device_name']); const stored = await chrome.storage.local.get(['device_name']);
const currentDeviceName: string | undefined = stored.device_name as string | undefined; const currentDeviceName: string | undefined = stored.device_name as string | undefined;
// Fetch device list // Fetch active device list and revoked list in parallel
const resp = await sendMessage({ type: 'list_devices' }); const [devicesResp, revokedResp] = await Promise.all([
if (!resp.ok) { sendMessage({ type: 'list_devices' }),
sendMessage({ type: 'list_revoked' }),
]);
if (!devicesResp.ok) {
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`; app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
return; return;
} }
const devices = (resp.data as { devices: Device[] }).devices; const devices = (devicesResp.data as { devices: Device[] }).devices;
const revokedDevices: RevokedEntry[] = revokedResp.ok
? (revokedResp.data as { revoked: RevokedEntry[] }).revoked
: [];
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName); const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
const activeDevicesHtml = devices.length === 0
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
: devices.map((d) => {
const isCurrentDevice = d.name === currentDeviceName;
return `
<div class="device-row">
<div class="device-row__info">
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
</div>
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
</div>
`;
}).join('');
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
<details class="revoked-section" style="margin-top:16px;">
<summary class="muted" style="cursor:pointer;font-size:0.85em;">
${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}
</summary>
<div style="margin-top:8px;">
${revokedDevices.map((r) => `
<div class="device-row device-row--revoked">
<div class="device-row__info">
<span class="device-row__name" style="text-decoration:line-through;opacity:0.5;">
${escapeHtml(r.name)}
</span>
<span class="device-row__meta">
revoked ${relativeTime(r.revoked_at)}
${r.revoked_by !== 'unknown' ? ` by ${escapeHtml(r.revoked_by)}` : ''}
</span>
</div>
</div>
`).join('')}
</div>
</details>
`;
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div class="devices-header"> <div class="devices-header">
@@ -58,20 +111,8 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
<button class="btn btn-primary" id="register-btn">Register this device</button> <button class="btn btn-primary" id="register-btn">Register this device</button>
</div> </div>
` : ''} ` : ''}
${devices.length === 0 ${activeDevicesHtml}
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>` ${revokedSectionHtml}
: devices.map((d) => {
const isCurrentDevice = d.name === currentDeviceName;
return `
<div class="device-row">
<div class="device-row__info">
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
</div>
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
</div>
`;
}).join('')}
</div> </div>
`; `;

View File

@@ -346,6 +346,12 @@ export async function handle(
return { ok: true, data: { devices: list } }; return { ok: true, data: { devices: list } };
} }
case 'list_revoked': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
const revoked = await devices.readRevoked(state.gitHost);
return { ok: true, data: { revoked } };
}
case 'add_device': { case 'add_device': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' }; if (!state.gitHost) return { ok: false, error: 'vault_locked' };
const device = { const device = {

View File

@@ -1049,12 +1049,12 @@ function attachStep5(): void {
try { try {
const w = await loadWasm(); const w = await loadWasm();
const keypair = w.generate_device_keypair(); // register_device keeps private keys internal — only public keys returned
const keypair = w.register_device(state.deviceName);
// 1) Save private key + name locally. // 1) Save device name locally (private keys stay in WASM memory).
await chrome.storage.local.set({ await chrome.storage.local.set({
device_name: state.deviceName, device_name: state.deviceName,
device_private_key: keypair.private_key_base64,
}); });
// 2) Save vault config + reference image to extension storage. // 2) Save vault config + reference image to extension storage.
@@ -1086,7 +1086,7 @@ function attachStep5(): void {
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
await addDevice(host, { await addDevice(host, {
name: state.deviceName, name: state.deviceName,
public_key: keypair.public_key_hex, public_key: keypair.signing_public_key,
added_at: Math.floor(Date.now() / 1000), added_at: Math.floor(Date.now() / 1000),
}); });

View File

@@ -41,6 +41,7 @@ export type PopupMessage =
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer } | { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
| { type: 'download_attachment'; itemId: string; attachmentId: string } | { type: 'download_attachment'; itemId: string; attachmentId: string }
| { type: 'list_devices' } | { type: 'list_devices' }
| { type: 'list_revoked' }
| { type: 'add_device'; name: string; public_key: string } | { type: 'add_device'; name: string; public_key: string }
| { type: 'register_this_device'; name: string } | { type: 'register_this_device'; name: string }
| { type: 'revoke_device'; name: string } | { type: 'revoke_device'; name: string }
@@ -139,6 +140,10 @@ export interface ListDevicesResponse extends Extract<Response, { ok: true }> {
data: { devices: Device[] }; data: { devices: Device[] };
} }
export interface ListRevokedResponse extends Extract<Response, { ok: true }> {
data: { revoked: Array<{ name: string; public_key: string; revoked_at: number; revoked_by: string }> };
}
export interface ListTrashedResponse extends Extract<Response, { ok: true }> { export interface ListTrashedResponse extends Extract<Response, { ok: true }> {
data: { items: Array<[ItemId, ManifestEntry]> }; data: { items: Array<[ItemId, ManifestEntry]> };
} }
@@ -161,7 +166,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'ack_autofill_origin', 'get_settings', 'update_settings', 'ack_autofill_origin', 'get_settings', 'update_settings',
'get_vault_settings', 'update_vault_settings', 'get_blacklist', 'get_vault_settings', 'update_vault_settings', 'get_blacklist',
'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment', 'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment',
'list_devices', 'add_device', 'register_this_device', 'revoke_device', 'list_devices', 'list_revoked', 'add_device', 'register_this_device', 'revoke_device',
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash', 'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
'get_field_history', 'get_field_history',
'get_session_config', 'update_session_config', 'get_session_config', 'update_session_config',