feat(encryptid): device management — labeled passkey list + nudge fix

Add label column to credentials, PATCH/DELETE endpoints for rename/remove,
device list UI in account modal with rename/remove actions, and clear stale
nudge dismiss timestamp after device registration so multiDevice API check
takes over permanently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 18:04:20 -07:00
parent c0b4250e96
commit 676e29902e
4 changed files with 183 additions and 10 deletions

View File

@ -1155,6 +1155,10 @@ export class RStackIdentity extends HTMLElement {
let guardiansLoaded = false; let guardiansLoaded = false;
let guardiansLoading = false; let guardiansLoading = false;
let devices: { credentialId: string; label: string | null; createdAt: number; lastUsed?: number; transports?: string[] }[] = [];
let devicesLoaded = false;
let devicesLoading = false;
let addresses: { id: string; street: string; city: string; state: string; zip: string; country: string }[] = []; let addresses: { id: string; street: string; city: string; state: string; zip: string; country: string }[] = [];
let addressesLoaded = false; let addressesLoaded = false;
let addressesLoading = false; let addressesLoading = false;
@ -1271,15 +1275,44 @@ export class RStackIdentity extends HTMLElement {
const renderDeviceSection = () => { const renderDeviceSection = () => {
const isOpen = openSection === "device"; const isOpen = openSection === "device";
const done = acctStatus ? acctStatus.multiDevice : null; const done = acctStatus ? acctStatus.multiDevice : null;
const body = isOpen ? ` let body = "";
<div class="account-section-body"> if (isOpen) {
<p style="color:var(--rs-text-secondary);font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.${acctStatus ? ` <span style="color:var(--rs-text-muted)">(${acctStatus.credentialCount} passkey${acctStatus.credentialCount !== 1 ? "s" : ""} registered)</span>` : ""}</p> if (devicesLoading) {
<div class="actions actions--stack"> body = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:var(--rs-text-secondary)"><span class="spinner"></span> Loading devices...</div></div>`;
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button> } else {
</div> const fmtDate = (ts?: number) => ts ? new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "Never";
<div class="error" id="device-error"></div> const transportBadge = (t: string) => `<span style="display:inline-block;background:var(--rs-bg-secondary);border-radius:4px;padding:1px 5px;font-size:0.7rem;color:var(--rs-text-muted)">${t}</span>`;
<div class="info-text">Each device you register can independently sign in to your account.</div>
</div>` : ""; const deviceListHTML = devices.length > 0
? `<div class="contact-list">${devices.map(d => `
<div class="contact-item" style="flex-wrap:wrap;gap:6px">
<div style="display:flex;align-items:center;gap:8px;min-width:0;flex:1">
<span style="font-size:1.1rem">🔑</span>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
<span style="font-weight:500">${(d.label || "Unnamed device").replace(/</g, "&lt;")}</span>
<span style="font-size:0.7rem;color:var(--rs-text-muted)">Added ${fmtDate(d.createdAt)} · Last used ${fmtDate(d.lastUsed)}</span>
${d.transports?.length ? `<div style="display:flex;gap:3px;flex-wrap:wrap">${d.transports.map(transportBadge).join("")}</div>` : ""}
</div>
</div>
<div style="display:flex;gap:4px;align-items:center">
<button class="btn btn--small" data-rename-credential="${d.credentialId}" title="Rename"></button>
<button class="btn btn--small btn--danger" data-remove-credential="${d.credentialId}" title="Remove"${devices.length <= 1 ? " disabled" : ""}>&times;</button>
</div>
</div>
`).join("")}</div>` : "";
body = `
<div class="account-section-body">
<p style="color:var(--rs-text-secondary);font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.</p>
${deviceListHTML}
<div class="actions actions--stack" style="margin-top:8px">
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
</div>
<div class="error" id="device-error"></div>
<div class="info-text">Each device you register can independently sign in to your account.</div>
</div>`;
}
}
return ` return `
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}"> <div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="device"> <div class="account-section-header" data-section="device">
@ -1469,6 +1502,30 @@ export class RStackIdentity extends HTMLElement {
render(); render();
}; };
const loadDevices = async () => {
if (devicesLoaded || devicesLoading) return;
devicesLoading = true;
render();
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/credentials`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
const data = await res.json();
devices = (data.credentials || []).map((c: any) => ({
credentialId: c.credentialId,
label: c.label || null,
createdAt: c.createdAt,
lastUsed: c.lastUsed,
transports: c.transports,
}));
}
} catch { /* offline */ }
devicesLoaded = true;
devicesLoading = false;
render();
};
const attachListeners = () => { const attachListeners = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close); overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
@ -1480,6 +1537,7 @@ export class RStackIdentity extends HTMLElement {
openSection = openSection === section ? null : section; openSection = openSection === section ? null : section;
if (openSection === "recovery") loadGuardians(); if (openSection === "recovery") loadGuardians();
if (openSection === "address") loadAddresses(); if (openSection === "address") loadAddresses();
if (openSection === "device") loadDevices();
render(); render();
if (openSection === "email") setTimeout(() => (overlay.querySelector("#acct-email") as HTMLInputElement)?.focus(), 50); if (openSection === "email") setTimeout(() => (overlay.querySelector("#acct-email") as HTMLInputElement)?.focus(), 50);
if (openSection === "recovery") setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50); if (openSection === "recovery") setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
@ -1596,6 +1654,9 @@ export class RStackIdentity extends HTMLElement {
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed"); if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
if (acctStatus) { acctStatus.credentialCount++; acctStatus.multiDevice = acctStatus.credentialCount > 1; } if (acctStatus) { acctStatus.credentialCount++; acctStatus.multiDevice = acctStatus.credentialCount > 1; }
localStorage.removeItem("eid_device_nudge_dismissed");
devicesLoaded = false;
loadDevices();
btn.innerHTML = "Device Registered"; btn.innerHTML = "Device Registered";
btn.className = "btn btn--success"; btn.className = "btn btn--success";
render(); render();
@ -1714,6 +1775,50 @@ export class RStackIdentity extends HTMLElement {
}); });
}); });
// Device: rename credential
overlay.querySelectorAll("[data-rename-credential]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.renameCredential!;
const device = devices.find(d => d.credentialId === id);
const newLabel = prompt("Enter a label for this device:", device?.label || "");
if (!newLabel || !newLabel.trim()) return;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/credentials/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ label: newLabel.trim() }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to rename");
if (device) device.label = newLabel.trim();
render();
} catch (e: any) {
const err = overlay.querySelector("#device-error") as HTMLElement;
if (err) err.textContent = e.message;
}
});
});
// Device: remove credential
overlay.querySelectorAll("[data-remove-credential]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.removeCredential!;
if (!confirm("Remove this passkey? You won't be able to sign in with it anymore.")) return;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/credentials/${encodeURIComponent(id)}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to remove");
devices = devices.filter(d => d.credentialId !== id);
if (acctStatus) { acctStatus.credentialCount = devices.length; acctStatus.multiDevice = devices.length > 1; }
render();
} catch (e: any) {
const err = overlay.querySelector("#device-error") as HTMLElement;
if (err) err.textContent = e.message;
}
});
});
// Data Storage toggle // Data Storage toggle
const backupToggle = overlay.querySelector("#acct-backup-toggle") as HTMLInputElement; const backupToggle = overlay.querySelector("#acct-backup-toggle") as HTMLInputElement;
if (backupToggle) { if (backupToggle) {

View File

@ -39,6 +39,7 @@ export interface StoredCredential {
lastUsed?: number; lastUsed?: number;
transports?: string[]; transports?: string[];
rpId?: string; rpId?: string;
label?: string;
} }
export interface StoredChallenge { export interface StoredChallenge {
@ -136,7 +137,7 @@ export async function updateCredentialUsage(credentialId: string, newCounter: nu
export async function getUserCredentials(userId: string): Promise<StoredCredential[]> { export async function getUserCredentials(userId: string): Promise<StoredCredential[]> {
const rows = await sql` const rows = await sql`
SELECT c.credential_id, c.public_key, c.user_id, c.counter, SELECT c.credential_id, c.public_key, c.user_id, c.counter,
c.transports, c.created_at, c.last_used, u.username c.transports, c.created_at, c.last_used, c.label, u.username
FROM credentials c FROM credentials c
JOIN users u ON c.user_id = u.id JOIN users u ON c.user_id = u.id
WHERE c.user_id = ${userId} WHERE c.user_id = ${userId}
@ -151,9 +152,28 @@ export async function getUserCredentials(userId: string): Promise<StoredCredenti
createdAt: new Date(row.created_at).getTime(), createdAt: new Date(row.created_at).getTime(),
lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined, lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined,
transports: row.transports, transports: row.transports,
label: row.label || undefined,
})); }));
} }
export async function updateCredentialLabel(credentialId: string, userId: string, label: string): Promise<boolean> {
const result = await sql`
UPDATE credentials SET label = ${label}
WHERE credential_id = ${credentialId} AND user_id = ${userId}
RETURNING credential_id
`;
return result.length > 0;
}
export async function deleteCredential(credentialId: string, userId: string): Promise<boolean> {
const result = await sql`
DELETE FROM credentials
WHERE credential_id = ${credentialId} AND user_id = ${userId}
RETURNING credential_id
`;
return result.length > 0;
}
// ============================================================================ // ============================================================================
// CHALLENGE OPERATIONS // CHALLENGE OPERATIONS
// ============================================================================ // ============================================================================

View File

@ -42,6 +42,9 @@ CREATE TABLE IF NOT EXISTS credentials (
CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id); CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id);
-- Device label for user-facing credential management
ALTER TABLE credentials ADD COLUMN IF NOT EXISTS label TEXT;
CREATE TABLE IF NOT EXISTS challenges ( CREATE TABLE IF NOT EXISTS challenges (
challenge TEXT PRIMARY KEY, challenge TEXT PRIMARY KEY,
user_id TEXT, user_id TEXT,

View File

@ -100,6 +100,8 @@ import {
getLinkedWallets, getLinkedWallets,
deleteLinkedWallet, deleteLinkedWallet,
linkedWalletExists, linkedWalletExists,
updateCredentialLabel,
deleteCredential,
createLegacyIdentity, createLegacyIdentity,
getLegacyIdentityByPublicKeyHash, getLegacyIdentityByPublicKeyHash,
getLegacyIdentitiesByUser, getLegacyIdentitiesByUser,
@ -972,6 +974,7 @@ app.get('/api/user/credentials', async (c) => {
createdAt: cred.createdAt, createdAt: cred.createdAt,
lastUsed: cred.lastUsed, lastUsed: cred.lastUsed,
transports: cred.transports, transports: cred.transports,
label: cred.label ?? null,
})); }));
return c.json({ credentials: credentialList }); return c.json({ credentials: credentialList });
@ -980,6 +983,48 @@ app.get('/api/user/credentials', async (c) => {
} }
}); });
/**
* Rename a credential (passkey label)
*/
app.patch('/api/user/credentials/:credentialId', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const credentialId = c.req.param('credentialId');
const { label } = await c.req.json();
if (!label || typeof label !== 'string' || label.trim().length === 0 || label.trim().length > 100) {
return c.json({ error: 'Label must be a non-empty string (max 100 chars)' }, 400);
}
const updated = await updateCredentialLabel(credentialId, claims.sub as string, label.trim());
if (!updated) return c.json({ error: 'Credential not found' }, 404);
return c.json({ success: true });
});
/**
* Remove a credential (passkey) refuses to delete the last one
*/
app.delete('/api/user/credentials/:credentialId', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const userId = claims.sub as string;
const credentialId = c.req.param('credentialId');
const creds = await getUserCredentials(userId);
if (creds.length <= 1) {
return c.json({ error: 'Cannot delete your only passkey' }, 400);
}
if (!creds.some(cr => cr.credentialId === credentialId)) {
return c.json({ error: 'Credential not found' }, 404);
}
await deleteCredential(credentialId, userId);
return c.json({ success: true });
});
// ============================================================================ // ============================================================================
// DID MIGRATION ENDPOINT // DID MIGRATION ENDPOINT
// ============================================================================ // ============================================================================