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:
parent
c0b4250e96
commit
676e29902e
|
|
@ -1155,6 +1155,10 @@ export class RStackIdentity extends HTMLElement {
|
|||
let guardiansLoaded = 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 addressesLoaded = false;
|
||||
let addressesLoading = false;
|
||||
|
|
@ -1271,15 +1275,44 @@ export class RStackIdentity extends HTMLElement {
|
|||
const renderDeviceSection = () => {
|
||||
const isOpen = openSection === "device";
|
||||
const done = acctStatus ? acctStatus.multiDevice : null;
|
||||
const body = isOpen ? `
|
||||
let body = "";
|
||||
if (isOpen) {
|
||||
if (devicesLoading) {
|
||||
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>`;
|
||||
} else {
|
||||
const fmtDate = (ts?: number) => ts ? new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "Never";
|
||||
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>`;
|
||||
|
||||
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, "<")}</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" : ""}>×</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.${acctStatus ? ` <span style="color:var(--rs-text-muted)">(${acctStatus.credentialCount} passkey${acctStatus.credentialCount !== 1 ? "s" : ""} registered)</span>` : ""}</p>
|
||||
<div class="actions actions--stack">
|
||||
<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>` : "";
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
return `
|
||||
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
|
||||
<div class="account-section-header" data-section="device">
|
||||
|
|
@ -1469,6 +1502,30 @@ export class RStackIdentity extends HTMLElement {
|
|||
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 = () => {
|
||||
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||
|
|
@ -1480,6 +1537,7 @@ export class RStackIdentity extends HTMLElement {
|
|||
openSection = openSection === section ? null : section;
|
||||
if (openSection === "recovery") loadGuardians();
|
||||
if (openSection === "address") loadAddresses();
|
||||
if (openSection === "device") loadDevices();
|
||||
render();
|
||||
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);
|
||||
|
|
@ -1596,6 +1654,9 @@ export class RStackIdentity extends HTMLElement {
|
|||
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
|
||||
|
||||
if (acctStatus) { acctStatus.credentialCount++; acctStatus.multiDevice = acctStatus.credentialCount > 1; }
|
||||
localStorage.removeItem("eid_device_nudge_dismissed");
|
||||
devicesLoaded = false;
|
||||
loadDevices();
|
||||
btn.innerHTML = "Device Registered";
|
||||
btn.className = "btn btn--success";
|
||||
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
|
||||
const backupToggle = overlay.querySelector("#acct-backup-toggle") as HTMLInputElement;
|
||||
if (backupToggle) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface StoredCredential {
|
|||
lastUsed?: number;
|
||||
transports?: string[];
|
||||
rpId?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface StoredChallenge {
|
||||
|
|
@ -136,7 +137,7 @@ export async function updateCredentialUsage(credentialId: string, newCounter: nu
|
|||
export async function getUserCredentials(userId: string): Promise<StoredCredential[]> {
|
||||
const rows = await sql`
|
||||
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
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.user_id = ${userId}
|
||||
|
|
@ -151,9 +152,28 @@ export async function getUserCredentials(userId: string): Promise<StoredCredenti
|
|||
createdAt: new Date(row.created_at).getTime(),
|
||||
lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined,
|
||||
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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ CREATE TABLE IF NOT EXISTS credentials (
|
|||
|
||||
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 (
|
||||
challenge TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ import {
|
|||
getLinkedWallets,
|
||||
deleteLinkedWallet,
|
||||
linkedWalletExists,
|
||||
updateCredentialLabel,
|
||||
deleteCredential,
|
||||
createLegacyIdentity,
|
||||
getLegacyIdentityByPublicKeyHash,
|
||||
getLegacyIdentitiesByUser,
|
||||
|
|
@ -972,6 +974,7 @@ app.get('/api/user/credentials', async (c) => {
|
|||
createdAt: cred.createdAt,
|
||||
lastUsed: cred.lastUsed,
|
||||
transports: cred.transports,
|
||||
label: cred.label ?? null,
|
||||
}));
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue