feat(extension): devices pane revamp — fingerprint + added-by + inline two-step revoke

This commit is contained in:
adlee-was-taken
2026-05-30 01:47:50 -04:00
parent 299e7db1ab
commit 047df6eb72
4 changed files with 123 additions and 128 deletions

View File

@@ -3,6 +3,8 @@
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';
import { relativeTime } from '../../shared/relative-time'; import { relativeTime } from '../../shared/relative-time';
import { sshFingerprint } from '../../shared/ssh-fingerprint';
import { GLYPH_REVOKE } from '../../shared/glyphs';
interface RevokedEntry { interface RevokedEntry {
name: string; name: string;
@@ -52,37 +54,47 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName); const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
// Precompute fingerprints for all active devices
const fingerprints = new Map<string, string>();
await Promise.all(devices.map(async (d) => {
const fp = await sshFingerprint(d.public_key);
fingerprints.set(d.name, fp ?? '(unknown)');
}));
const activeDevicesHtml = devices.length === 0 const activeDevicesHtml = devices.length === 0
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>` ? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
: devices.map((d) => { : devices.map((d) => {
const isCurrentDevice = d.name === currentDeviceName; const isCurrentDevice = d.name === currentDeviceName;
const fp = fingerprints.get(d.name) ?? '(unknown)';
const addedBy = d.added_by && d.added_by !== 'unknown' ? ` · by ${escapeHtml(d.added_by)}` : '';
return ` return `
<div class="device-row"> <div class="device-row" data-device="${escapeHtml(d.name)}">
<div class="device-row__info"> <div class="device-row__head">
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span> <span class="device-row__name">${escapeHtml(d.name)}</span>
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span> ${isCurrentDevice
? '<span class="device-row__you">← you</span>'
: `<button class="glyph-btn" data-danger data-revoke="${escapeHtml(d.name)}" title="revoke" aria-label="revoke ${escapeHtml(d.name)}">${GLYPH_REVOKE}</button>`}
</div> </div>
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`} <div class="fingerprint">${escapeHtml(fp)}</div>
<div class="device-row__meta">added ${escapeHtml(relativeTime(d.added_at))}${addedBy}</div>
<div class="device-row__confirm" data-confirm-for="${escapeHtml(d.name)}" hidden></div>
</div> </div>
`; `;
}).join(''); }).join('');
const revokedSectionHtml = revokedDevices.length === 0 ? '' : ` const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
<details class="revoked-section" style="margin-top:16px;"> <details class="revoked-section">
<summary class="muted" style="cursor:pointer;font-size:0.85em;"> <summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''} <div class="revoked-section__body">
</summary>
<div style="margin-top:8px;">
${revokedDevices.map((r) => ` ${revokedDevices.map((r) => `
<div class="device-row device-row--revoked"> <div class="device-row device-row--revoked">
<div class="device-row__info"> <div class="device-row__head">
<span class="device-row__name" style="text-decoration:line-through;opacity:0.5;"> <span class="device-row__name" style="text-decoration:line-through;opacity:0.6;">
${escapeHtml(r.name)} ${escapeHtml(r.name)}
</span> </span>
<span class="device-row__meta"> </div>
revoked ${relativeTime(r.revoked_at)} <div class="device-row__meta">
${r.revoked_by !== 'unknown' ? ` by ${escapeHtml(r.revoked_by)}` : ''} revoked ${escapeHtml(relativeTime(r.revoked_at))}${r.revoked_by !== 'unknown' ? ` · by ${escapeHtml(r.revoked_by)}` : ''}
</span>
</div> </div>
</div> </div>
`).join('')} `).join('')}
@@ -98,11 +110,14 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
</div> </div>
${!isRegistered ? ` ${!isRegistered ? `
<div class="device-banner"> <div class="device-banner">
<span>⚠ This device is not registered</span> <div class="device-banner__title">This device isn't registered.</div>
<p class="device-banner__body muted">Registering generates an ed25519 keypair and adds the public key to <code>.relicario/devices.json</code> on the remote.</p>
<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 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
${activeDevicesHtml} ${activeDevicesHtml}
${revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : ''}
${revokedSectionHtml} ${revokedSectionHtml}
</div> </div>
`; `;
@@ -151,20 +166,43 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
}); });
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => { document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', () => {
const name = btn.dataset.revoke; const name = btn.dataset.revoke;
if (!name) return; if (!name) return;
if (!confirm(`Revoke ${name}? This device will no longer be authorized.`)) return; const panel = document.querySelector<HTMLElement>(`[data-confirm-for="${CSS.escape(name)}"]`);
if (!panel) return;
panel.hidden = false;
panel.innerHTML = `
<p class="device-row__confirm-text">
Revoke this device? It won't be able to sign commits or push changes after revocation.
</p>
<div class="device-row__confirm-actions">
<button class="btn" data-revoke-cancel="${escapeHtml(name)}">cancel</button>
<button class="btn btn-danger" data-revoke-confirm="${escapeHtml(name)}">revoke</button>
</div>
`;
btn.disabled = true; btn.disabled = true;
btn.textContent = '...';
panel.querySelector('[data-revoke-cancel]')?.addEventListener('click', () => {
panel.hidden = true;
panel.innerHTML = '';
btn.disabled = false;
});
panel.querySelector('[data-revoke-confirm]')?.addEventListener('click', async () => {
const confirmBtn = panel.querySelector<HTMLButtonElement>('[data-revoke-confirm]')!;
confirmBtn.disabled = true;
confirmBtn.textContent = '...';
const result = await sendMessage({ type: 'revoke_device', name }); const result = await sendMessage({ type: 'revoke_device', name });
if (result.ok) { if (result.ok) {
await sendMessage({ type: 'sync' }); await sendMessage({ type: 'sync' });
renderDevices(app); renderDevices(app);
} else { } else {
setState({ error: result.error }); setState({ error: result.error });
confirmBtn.disabled = false;
confirmBtn.textContent = 'revoke';
} }
}); });
}); });
});
} }

View File

@@ -1169,69 +1169,47 @@ textarea {
margin-bottom: 12px; margin-bottom: 12px;
} }
.device-banner { .device-row {
padding: 10px 0;
border-bottom: 1px solid var(--border-subtle);
}
.device-row__head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 10px; margin-bottom: 2px;
background: #3d1f00;
border: 1px solid #9e6a03;
border-radius: 4px;
margin-bottom: 12px;
font-size: 12px;
color: #f0c674;
} }
.device-row__name { color: var(--text); }
.device-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
border-radius: 4px;
background: #161b22;
margin-bottom: 6px;
}
.device-row__info {
flex: 1;
min-width: 0;
}
.device-row__name {
display: block;
font-size: 13px;
color: #c9d1d9;
}
.device-row__you { .device-row__you {
font-size: 11px; font-size: 11px;
color: #58a6ff; color: var(--text-muted);
margin-left: 8px;
} }
.device-row__meta { .device-row__meta {
font-size: 11px; font-size: 11px;
color: #8b949e; color: var(--text-muted);
margin-top: 2px;
} }
.device-row__confirm {
margin-top: 8px;
padding: 10px;
border: 1px solid var(--border-subtle);
border-radius: 3px;
background: var(--bg-input);
}
.device-row__confirm-text { margin: 0 0 8px 0; color: var(--text); }
.device-row__confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
.device-row__revoke { .device-banner {
font-size: 11px; padding: 12px;
padding: 4px 8px; border: 1px solid var(--border-subtle);
background: #da3633; border-radius: 3px;
color: #fff; background: var(--bg-pane);
border: none; margin-bottom: 16px;
border-radius: 4px;
cursor: pointer;
}
.device-row__revoke:hover {
background: #f85149;
}
.device-row__revoke:disabled {
opacity: 0.5;
cursor: default;
} }
.device-banner__title { margin-bottom: 4px; }
.device-banner__body { font-size: 12px; margin: 0 0 10px 0; }
/* --- Field history view --- */ /* --- Field history view --- */

View File

@@ -150,6 +150,7 @@ export interface Device {
name: string; name: string;
public_key: string; // hex-encoded ed25519 pubkey public_key: string; // hex-encoded ed25519 pubkey
added_at: number; // unix timestamp added_at: number; // unix timestamp
added_by?: string; // device name that registered this device (optional)
} }
// --- Field history view --- // --- Field history view ---

View File

@@ -1089,69 +1089,47 @@ textarea {
margin-bottom: 12px; margin-bottom: 12px;
} }
.device-banner { .device-row {
padding: 10px 0;
border-bottom: 1px solid var(--border-subtle);
}
.device-row__head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 10px; margin-bottom: 2px;
background: #3d1f00;
border: 1px solid #9e6a03;
border-radius: 4px;
margin-bottom: 12px;
font-size: 12px;
color: #f0c674;
} }
.device-row__name { color: var(--text); }
.device-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
border-radius: 4px;
background: #161b22;
margin-bottom: 6px;
}
.device-row__info {
flex: 1;
min-width: 0;
}
.device-row__name {
display: block;
font-size: 13px;
color: #c9d1d9;
}
.device-row__you { .device-row__you {
font-size: 11px; font-size: 11px;
color: #58a6ff; color: var(--text-muted);
margin-left: 8px;
} }
.device-row__meta { .device-row__meta {
font-size: 11px; font-size: 11px;
color: #8b949e; color: var(--text-muted);
margin-top: 2px;
} }
.device-row__confirm {
margin-top: 8px;
padding: 10px;
border: 1px solid var(--border-subtle);
border-radius: 3px;
background: var(--bg-input);
}
.device-row__confirm-text { margin: 0 0 8px 0; color: var(--text); }
.device-row__confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
.device-row__revoke { .device-banner {
font-size: 11px; padding: 12px;
padding: 4px 8px; border: 1px solid var(--border-subtle);
background: #da3633; border-radius: 3px;
color: #fff; background: var(--bg-pane);
border: none; margin-bottom: 16px;
border-radius: 4px;
cursor: pointer;
}
.device-row__revoke:hover {
background: #f85149;
}
.device-row__revoke:disabled {
opacity: 0.5;
cursor: default;
} }
.device-banner__title { margin-bottom: 4px; }
.device-banner__body { font-size: 12px; margin: 0 0 10px 0; }
/* --- Field history view --- */ /* --- Field history view --- */