feat(extension): devices pane revamp — fingerprint + added-by + inline two-step revoke
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import type { Device } from '../../shared/types';
|
||||
import { relativeTime } from '../../shared/relative-time';
|
||||
import { sshFingerprint } from '../../shared/ssh-fingerprint';
|
||||
import { GLYPH_REVOKE } from '../../shared/glyphs';
|
||||
|
||||
interface RevokedEntry {
|
||||
name: string;
|
||||
@@ -52,37 +54,47 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||
|
||||
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
|
||||
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
||||
: devices.map((d) => {
|
||||
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 `
|
||||
<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 class="device-row" data-device="${escapeHtml(d.name)}">
|
||||
<div class="device-row__head">
|
||||
<span class="device-row__name">${escapeHtml(d.name)}</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>
|
||||
${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>
|
||||
`;
|
||||
}).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;">
|
||||
<details class="revoked-section">
|
||||
<summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
|
||||
<div class="revoked-section__body">
|
||||
${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;">
|
||||
<div class="device-row__head">
|
||||
<span class="device-row__name" style="text-decoration:line-through;opacity:0.6;">
|
||||
${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 class="device-row__meta">
|
||||
revoked ${escapeHtml(relativeTime(r.revoked_at))}${r.revoked_by !== 'unknown' ? ` · by ${escapeHtml(r.revoked_by)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
@@ -98,11 +110,14 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||
</div>
|
||||
${!isRegistered ? `
|
||||
<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>
|
||||
</div>
|
||||
` : ''}
|
||||
${devices.length > 0 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
|
||||
${activeDevicesHtml}
|
||||
${revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : ''}
|
||||
${revokedSectionHtml}
|
||||
</div>
|
||||
`;
|
||||
@@ -151,20 +166,43 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.addEventListener('click', () => {
|
||||
const name = btn.dataset.revoke;
|
||||
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.textContent = '...';
|
||||
const result = await sendMessage({ type: 'revoke_device', name });
|
||||
if (result.ok) {
|
||||
await sendMessage({ type: 'sync' });
|
||||
renderDevices(app);
|
||||
} else {
|
||||
setState({ error: result.error });
|
||||
}
|
||||
|
||||
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 });
|
||||
if (result.ok) {
|
||||
await sendMessage({ type: 'sync' });
|
||||
renderDevices(app);
|
||||
} else {
|
||||
setState({ error: result.error });
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'revoke';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1169,69 +1169,47 @@ textarea {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.device-banner {
|
||||
.device-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.device-row__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #3d1f00;
|
||||
border: 1px solid #9e6a03;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #f0c674;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.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__name { color: var(--text); }
|
||||
.device-row__you {
|
||||
font-size: 11px;
|
||||
color: #58a6ff;
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.device-row__meta {
|
||||
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 {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
background: #da3633;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-row__revoke:hover {
|
||||
background: #f85149;
|
||||
}
|
||||
|
||||
.device-row__revoke:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
.device-banner {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-pane);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.device-banner__title { margin-bottom: 4px; }
|
||||
.device-banner__body { font-size: 12px; margin: 0 0 10px 0; }
|
||||
|
||||
/* --- Field history view --- */
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ export interface Device {
|
||||
name: string;
|
||||
public_key: string; // hex-encoded ed25519 pubkey
|
||||
added_at: number; // unix timestamp
|
||||
added_by?: string; // device name that registered this device (optional)
|
||||
}
|
||||
|
||||
// --- Field history view ---
|
||||
|
||||
@@ -1089,69 +1089,47 @@ textarea {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.device-banner {
|
||||
.device-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.device-row__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #3d1f00;
|
||||
border: 1px solid #9e6a03;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #f0c674;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.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__name { color: var(--text); }
|
||||
.device-row__you {
|
||||
font-size: 11px;
|
||||
color: #58a6ff;
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.device-row__meta {
|
||||
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 {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
background: #da3633;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-row__revoke:hover {
|
||||
background: #f85149;
|
||||
}
|
||||
|
||||
.device-row__revoke:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
.device-banner {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-pane);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.device-banner__title { margin-bottom: 4px; }
|
||||
.device-banner__body { font-size: 12px; margin: 0 0 10px 0; }
|
||||
|
||||
/* --- Field history view --- */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user