fix(encryptid): unified device list with names, confirmation, and rename/delete

- Add detectDeviceName() JS helper to all 6 registration pages (parses
  UA → "Chrome on Windows", "Safari on iPhone", etc.)
- Accept deviceName in /api/register/complete, /api/account/device/complete,
  and /api/device-link/:token/complete; store as credential label at creation
- Add optional label param to storeCredential() in db.ts
- Replace separate "Your Passkeys" section with unified device list in
  "Linked Devices" showing name, status, created/last-used dates, and
  inline rename (PATCH) and delete (DELETE) actions
- Make checklist "Second device" confirmation-aware: only marks done when
  a second device has actually been used to sign in (has lastUsed set)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-13 11:23:05 -04:00
parent 195b42eb3b
commit cede2232b5
2 changed files with 273 additions and 39 deletions

View File

@ -84,13 +84,13 @@ export async function getUserByUsername(username: string) {
// CREDENTIAL OPERATIONS // CREDENTIAL OPERATIONS
// ============================================================================ // ============================================================================
export async function storeCredential(cred: StoredCredential, did?: string): Promise<void> { export async function storeCredential(cred: StoredCredential, did?: string, label?: string): Promise<void> {
// Ensure user exists first (with display name + DID so they're never NULL) // Ensure user exists first (with display name + DID so they're never NULL)
// If a proper DID is provided (e.g. from PRF key derivation), use it; otherwise omit // If a proper DID is provided (e.g. from PRF key derivation), use it; otherwise omit
await createUser(cred.userId, cred.username, cred.username, did || undefined); await createUser(cred.userId, cred.username, cred.username, did || undefined);
await sql` await sql`
INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at, rp_id) INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at, rp_id, label)
VALUES ( VALUES (
${cred.credentialId}, ${cred.credentialId},
${cred.userId}, ${cred.userId},
@ -98,7 +98,8 @@ export async function storeCredential(cred: StoredCredential, did?: string): Pro
${cred.counter}, ${cred.counter},
${cred.transports || null}, ${cred.transports || null},
${new Date(cred.createdAt)}, ${new Date(cred.createdAt)},
${cred.rpId || 'rspace.online'} ${cred.rpId || 'rspace.online'},
${label || null}
) )
`; `;
} }

View File

@ -600,7 +600,7 @@ app.post('/api/register/complete', async (c) => {
if (!body) { if (!body) {
return c.json({ error: 'Invalid request body' }, 400); return c.json({ error: 'Invalid request body' }, 400);
} }
const { challenge, credential, userId, username, email, clientDid, eoaAddress } = body; const { challenge, credential, userId, username, email, clientDid, eoaAddress, deviceName } = body;
if (!userId || !credential || !username) { if (!userId || !credential || !username) {
return c.json({ error: 'Missing required fields: userId, credential, username' }, 400); return c.json({ error: 'Missing required fields: userId, credential, username' }, 400);
@ -644,7 +644,8 @@ app.post('/api/register/complete', async (c) => {
transports: credential.transports, transports: credential.transports,
rpId, rpId,
}; };
await storeCredential(storedCredential, did); const credLabel = (typeof deviceName === 'string' && deviceName.trim()) ? deviceName.trim().slice(0, 100) : undefined;
await storeCredential(storedCredential, did, credLabel);
// Store wallet address if provided (from PRF-derived EOA) // Store wallet address if provided (from PRF-derived EOA)
if (eoaAddress && typeof eoaAddress === 'string' && eoaAddress.startsWith('0x')) { if (eoaAddress && typeof eoaAddress === 'string' && eoaAddress.startsWith('0x')) {
@ -1869,7 +1870,7 @@ app.post('/api/account/device/complete', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization')); const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401); if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { challenge, credential } = await c.req.json(); const { challenge, credential, deviceName } = await c.req.json();
if (!challenge || !credential?.credentialId) { if (!challenge || !credential?.credentialId) {
return c.json({ error: 'Challenge and credential required' }, 400); return c.json({ error: 'Challenge and credential required' }, 400);
} }
@ -1891,6 +1892,7 @@ app.post('/api/account/device/complete', async (c) => {
if (!user) return c.json({ error: 'User not found' }, 404); if (!user) return c.json({ error: 'User not found' }, 404);
const rpId = resolveRpId(c); const rpId = resolveRpId(c);
const devLabel = (typeof deviceName === 'string' && deviceName.trim()) ? deviceName.trim().slice(0, 100) : undefined;
await storeCredential({ await storeCredential({
credentialId: credential.credentialId, credentialId: credential.credentialId,
publicKey: credential.publicKey || '', publicKey: credential.publicKey || '',
@ -1900,7 +1902,7 @@ app.post('/api/account/device/complete', async (c) => {
createdAt: Date.now(), createdAt: Date.now(),
transports: credential.transports || [], transports: credential.transports || [],
rpId, rpId,
}); }, undefined, devLabel);
console.log('EncryptID: Additional device registered for', user.username); console.log('EncryptID: Additional device registered for', user.username);
return c.json({ success: true }); return c.json({ success: true });
@ -2336,6 +2338,25 @@ app.get('/recover', (c) => {
</div> </div>
<script> <script>
function detectDeviceName() {
const ua = navigator.userAgent;
let browser = 'Unknown browser', os = 'Unknown OS';
if (/CriOS/i.test(ua)) browser = 'Chrome';
else if (/FxiOS/i.test(ua)) browser = 'Firefox';
else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
else if (/Edg/i.test(ua)) browser = 'Edge';
else if (/Firefox/i.test(ua)) browser = 'Firefox';
else if (/Chrome/i.test(ua)) browser = 'Chrome';
if (/iPhone/i.test(ua)) os = 'iPhone';
else if (/iPad/i.test(ua)) os = 'iPad';
else if (/Android/i.test(ua)) os = 'Android';
else if (/Mac OS/i.test(ua)) os = 'macOS';
else if (/Windows/i.test(ua)) os = 'Windows';
else if (/Linux/i.test(ua)) os = 'Linux';
else if (/CrOS/i.test(ua)) os = 'ChromeOS';
return browser + ' on ' + os;
}
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const recoveryToken = params.get('token'); const recoveryToken = params.get('token');
let sessionToken = null; let sessionToken = null;
@ -2405,7 +2426,7 @@ app.get('/recover', (c) => {
const completeRes = await fetch('/api/register/complete', { const completeRes = await fetch('/api/register/complete', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username }), body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username, deviceName: detectDeviceName() }),
}); });
const result = await completeRes.json(); const result = await completeRes.json();
if (result.success) { if (result.success) {
@ -2498,6 +2519,25 @@ app.get('/recover/social', (c) => {
</div> </div>
<script> <script>
function detectDeviceName() {
const ua = navigator.userAgent;
let browser = 'Unknown browser', os = 'Unknown OS';
if (/CriOS/i.test(ua)) browser = 'Chrome';
else if (/FxiOS/i.test(ua)) browser = 'Firefox';
else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
else if (/Edg/i.test(ua)) browser = 'Edge';
else if (/Firefox/i.test(ua)) browser = 'Firefox';
else if (/Chrome/i.test(ua)) browser = 'Chrome';
if (/iPhone/i.test(ua)) os = 'iPhone';
else if (/iPad/i.test(ua)) os = 'iPad';
else if (/Android/i.test(ua)) os = 'Android';
else if (/Mac OS/i.test(ua)) os = 'macOS';
else if (/Windows/i.test(ua)) os = 'Windows';
else if (/Linux/i.test(ua)) os = 'Linux';
else if (/CrOS/i.test(ua)) os = 'ChromeOS';
return browser + ' on ' + os;
}
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const requestId = params.get('id'); const requestId = params.get('id');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
@ -2616,7 +2656,7 @@ app.get('/recover/social', (c) => {
const completeRes = await fetch('/api/register/complete', { const completeRes = await fetch('/api/register/complete', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username }), body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username, deviceName: detectDeviceName() }),
}); });
const result = await completeRes.json(); const result = await completeRes.json();
if (result.success) { if (result.success) {
@ -2894,6 +2934,25 @@ app.get('/guardian', (c) => {
<script type="module"> <script type="module">
import { bufferToBase64url, base64urlToBuffer } from '/dist/index.js'; import { bufferToBase64url, base64urlToBuffer } from '/dist/index.js';
function detectDeviceName() {
const ua = navigator.userAgent;
let browser = 'Unknown browser', os = 'Unknown OS';
if (/CriOS/i.test(ua)) browser = 'Chrome';
else if (/FxiOS/i.test(ua)) browser = 'Firefox';
else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
else if (/Edg/i.test(ua)) browser = 'Edge';
else if (/Firefox/i.test(ua)) browser = 'Firefox';
else if (/Chrome/i.test(ua)) browser = 'Chrome';
if (/iPhone/i.test(ua)) os = 'iPhone';
else if (/iPad/i.test(ua)) os = 'iPad';
else if (/Android/i.test(ua)) os = 'Android';
else if (/Mac OS/i.test(ua)) os = 'macOS';
else if (/Windows/i.test(ua)) os = 'Windows';
else if (/Linux/i.test(ua)) os = 'Linux';
else if (/CrOS/i.test(ua)) os = 'ChromeOS';
return browser + ' on ' + os;
}
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const inviteToken = params.get('token'); const inviteToken = params.get('token');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
@ -2995,7 +3054,7 @@ app.get('/guardian', (c) => {
const completeRes = await fetch('/api/register/complete', { const completeRes = await fetch('/api/register/complete', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: serverOptions.challenge, credential: credentialData, userId, username }), body: JSON.stringify({ challenge: serverOptions.challenge, credential: credentialData, userId, username, deviceName: detectDeviceName() }),
}); });
const data = await completeRes.json(); const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || 'Registration failed'); if (!completeRes.ok || !data.success) throw new Error(data.error || 'Registration failed');
@ -3662,7 +3721,7 @@ app.post('/api/device-link/:token/complete', async (c) => {
if (link.used) return c.json({ error: 'Link already used' }, 400); if (link.used) return c.json({ error: 'Link already used' }, 400);
if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400); if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400);
const { credential } = await c.req.json(); const { credential, deviceName } = await c.req.json();
if (!credential?.credentialId || !credential?.publicKey) { if (!credential?.credentialId || !credential?.publicKey) {
return c.json({ error: 'Credential data required' }, 400); return c.json({ error: 'Credential data required' }, 400);
} }
@ -3672,6 +3731,7 @@ app.post('/api/device-link/:token/complete', async (c) => {
// Store the new credential under the same user // Store the new credential under the same user
const rpId = resolveRpId(c); const rpId = resolveRpId(c);
const linkLabel = (typeof deviceName === 'string' && deviceName.trim()) ? deviceName.trim().slice(0, 100) : undefined;
await storeCredential({ await storeCredential({
credentialId: credential.credentialId, credentialId: credential.credentialId,
publicKey: credential.publicKey, publicKey: credential.publicKey,
@ -3681,7 +3741,7 @@ app.post('/api/device-link/:token/complete', async (c) => {
createdAt: Date.now(), createdAt: Date.now(),
transports: credential.transports, transports: credential.transports,
rpId, rpId,
}); }, undefined, linkLabel);
await markDeviceLinkUsed(token); await markDeviceLinkUsed(token);
@ -3737,6 +3797,25 @@ app.get('/link', (c) => {
</div> </div>
<script> <script>
function detectDeviceName() {
const ua = navigator.userAgent;
let browser = 'Unknown browser', os = 'Unknown OS';
if (/CriOS/i.test(ua)) browser = 'Chrome';
else if (/FxiOS/i.test(ua)) browser = 'Firefox';
else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
else if (/Edg/i.test(ua)) browser = 'Edge';
else if (/Firefox/i.test(ua)) browser = 'Firefox';
else if (/Chrome/i.test(ua)) browser = 'Chrome';
if (/iPhone/i.test(ua)) os = 'iPhone';
else if (/iPad/i.test(ua)) os = 'iPad';
else if (/Android/i.test(ua)) os = 'Android';
else if (/Mac OS/i.test(ua)) os = 'macOS';
else if (/Windows/i.test(ua)) os = 'Windows';
else if (/Linux/i.test(ua)) os = 'Linux';
else if (/CrOS/i.test(ua)) os = 'ChromeOS';
return browser + ' on ' + os;
}
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const linkToken = params.get('token'); const linkToken = params.get('token');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
@ -3834,7 +3913,7 @@ app.get('/link', (c) => {
const completeRes = await fetch('/api/device-link/' + encodeURIComponent(linkToken) + '/complete', { const completeRes = await fetch('/api/device-link/' + encodeURIComponent(linkToken) + '/complete', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: credentialData }), body: JSON.stringify({ credential: credentialData, deviceName: detectDeviceName() }),
}); });
const data = await completeRes.json(); const data = await completeRes.json();
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
@ -6130,6 +6209,25 @@ function joinPage(token: string): string {
const TOKEN = ${JSON.stringify(token)}; const TOKEN = ${JSON.stringify(token)};
let inviteData = null; let inviteData = null;
function detectDeviceName() {
const ua = navigator.userAgent;
let browser = 'Unknown browser', os = 'Unknown OS';
if (/CriOS/i.test(ua)) browser = 'Chrome';
else if (/FxiOS/i.test(ua)) browser = 'Firefox';
else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
else if (/Edg/i.test(ua)) browser = 'Edge';
else if (/Firefox/i.test(ua)) browser = 'Firefox';
else if (/Chrome/i.test(ua)) browser = 'Chrome';
if (/iPhone/i.test(ua)) os = 'iPhone';
else if (/iPad/i.test(ua)) os = 'iPad';
else if (/Android/i.test(ua)) os = 'Android';
else if (/Mac OS/i.test(ua)) os = 'macOS';
else if (/Windows/i.test(ua)) os = 'Windows';
else if (/Linux/i.test(ua)) os = 'Linux';
else if (/CrOS/i.test(ua)) os = 'ChromeOS';
return browser + ' on ' + os;
}
const errorEl = document.getElementById('error'); const errorEl = document.getElementById('error');
const successEl = document.getElementById('success'); const successEl = document.getElementById('success');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
@ -6255,6 +6353,7 @@ function joinPage(token: string): string {
body: JSON.stringify({ body: JSON.stringify({
username, username,
challenge: options.challenge, challenge: options.challenge,
deviceName: detectDeviceName(),
credential: { credential: {
credentialId, credentialId,
attestationObject, attestationObject,
@ -6595,6 +6694,7 @@ function oidcAcceptPage(token: string): string {
body: JSON.stringify({ body: JSON.stringify({
username, username,
challenge: options.challenge, challenge: options.challenge,
deviceName: detectDeviceName(),
credential: { credential: {
credentialId: toB64url(credential.rawId), credentialId: toB64url(credential.rawId),
attestationObject: toB64url(credential.response.attestationObject), attestationObject: toB64url(credential.response.attestationObject),
@ -7548,12 +7648,19 @@ app.get('/', (c) => {
.btn-danger { border-color: rgba(239,68,68,0.4); color: #fca5a5; } .btn-danger { border-color: rgba(239,68,68,0.4); color: #fca5a5; }
.btn-danger:hover { background: rgba(239,68,68,0.15); } .btn-danger:hover { background: rgba(239,68,68,0.15); }
/* Passkeys list */ /* Device list */
.passkeys { margin-top: 1.5rem; } .device-item { padding: 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.8rem; }
.passkeys h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; } .device-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem; }
.passkey-item { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.8rem; } .device-name { color: #e2e8f0; font-weight: 500; }
.passkey-id { font-family: monospace; color: #94a3b8; } .device-status { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 9999px; }
.passkey-date { color: #64748b; } .device-status.active { background: rgba(34,197,94,0.15); color: #22c55e; }
.device-status.pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
.device-meta { color: #64748b; font-size: 0.75rem; }
.device-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.device-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 0.75rem; padding: 0.2rem 0.4rem; border-radius: 0.25rem; }
.device-actions button:hover { background: rgba(255,255,255,0.08); color: #e2e8f0; }
.device-actions button.danger:hover { color: #ef4444; }
.device-rename-input { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: #fff; padding: 0.3rem 0.5rem; border-radius: 0.25rem; font-size: 0.8rem; width: 100%; }
/* Recovery email */ /* Recovery email */
.recovery-section, .guardians-section, .devices-section, .vault-section, .wallet-section, .spaces-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); } .recovery-section, .guardians-section, .devices-section, .vault-section, .wallet-section, .spaces-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); }
@ -7815,11 +7922,6 @@ app.get('/', (c) => {
<div class="profile-row"><span class="label">Session</span><span id="profile-session">Active</span></div> <div class="profile-row"><span class="label">Session</span><span id="profile-session">Active</span></div>
<div class="profile-row"><span class="label">Token expires</span><span id="profile-expires">--</span></div> <div class="profile-row"><span class="label">Token expires</span><span id="profile-expires">--</span></div>
<div class="passkeys">
<h4>Your Passkeys</h4>
<div id="passkey-list"><div class="passkey-item"><span class="passkey-id">Loading...</span></div></div>
</div>
<div class="setup-checklist" id="setup-checklist"> <div class="setup-checklist" id="setup-checklist">
<h4>Account Security</h4> <h4>Account Security</h4>
<div class="check-item"><div class="check-icon done">&#10003;</div> Passkey created</div> <div class="check-item"><div class="check-icon done">&#10003;</div> Passkey created</div>
@ -7958,6 +8060,25 @@ app.get('/', (c) => {
authenticatePasskey, authenticatePasskey,
} from '/dist/index.js'; } from '/dist/index.js';
function detectDeviceName() {
const ua = navigator.userAgent;
let browser = 'Unknown browser', os = 'Unknown OS';
if (/CriOS/i.test(ua)) browser = 'Chrome';
else if (/FxiOS/i.test(ua)) browser = 'Firefox';
else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
else if (/Edg/i.test(ua)) browser = 'Edge';
else if (/Firefox/i.test(ua)) browser = 'Firefox';
else if (/Chrome/i.test(ua)) browser = 'Chrome';
if (/iPhone/i.test(ua)) os = 'iPhone';
else if (/iPad/i.test(ua)) os = 'iPad';
else if (/Android/i.test(ua)) os = 'Android';
else if (/Mac OS/i.test(ua)) os = 'macOS';
else if (/Windows/i.test(ua)) os = 'Windows';
else if (/Linux/i.test(ua)) os = 'Linux';
else if (/CrOS/i.test(ua)) os = 'ChromeOS';
return browser + ' on ' + os;
}
const TOKEN_KEY = 'encryptid_token'; const TOKEN_KEY = 'encryptid_token';
const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts'; const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts';
@ -8182,6 +8303,7 @@ app.get('/', (c) => {
credential: credentialData, credential: credentialData,
userId, username, userId, username,
}; };
regBody.deviceName = detectDeviceName();
if (email) regBody.email = email; if (email) regBody.email = email;
if (clientDid) regBody.clientDid = clientDid; if (clientDid) regBody.clientDid = clientDid;
if (eoaAddress) regBody.eoaAddress = eoaAddress; if (eoaAddress) regBody.eoaAddress = eoaAddress;
@ -8536,22 +8658,14 @@ app.get('/', (c) => {
document.getElementById('profile-expires').textContent = exp.toLocaleString(); document.getElementById('profile-expires').textContent = exp.toLocaleString();
} catch { document.getElementById('profile-expires').textContent = '--'; } } catch { document.getElementById('profile-expires').textContent = '--'; }
// Fetch passkeys // Fetch and render unified device list
try { try {
const res = await fetch('/api/user/credentials', { const res = await fetch('/api/user/credentials', {
headers: { 'Authorization': 'Bearer ' + token }, headers: { 'Authorization': 'Bearer ' + token },
}); });
const data = await res.json(); const data = await res.json();
const list = document.getElementById('passkey-list'); window._credentialsData = data.credentials || [];
if (data.credentials && data.credentials.length > 0) { renderDeviceList(data.credentials || []);
list.innerHTML = data.credentials.map(c => {
const created = c.createdAt ? new Date(c.createdAt).toLocaleDateString() : '';
return '<div class="passkey-item"><span class="passkey-id">' +
c.credentialId.slice(0, 24) + '...</span><span class="passkey-date">' + created + '</span></div>';
}).join('');
} else {
list.innerHTML = '<div class="passkey-item"><span class="passkey-id">No passkeys found</span></div>';
}
} catch { /* ignore */ } } catch { /* ignore */ }
// Load guardians, vault status, wallet, and spaces // Load guardians, vault status, wallet, and spaces
@ -8981,6 +9095,120 @@ app.get('/', (c) => {
} }
} }
// ── Device list rendering ──
function timeAgo(ts) {
if (!ts) return 'Never';
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return mins + 'm ago';
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + 'h ago';
const days = Math.floor(hrs / 24);
if (days < 30) return days + 'd ago';
return new Date(ts).toLocaleDateString();
}
function renderDeviceList(credentials) {
const list = document.getElementById('device-list');
if (!credentials || credentials.length === 0) {
list.innerHTML = '<p style="color:#64748b;font-size:0.8rem;">No devices found</p>';
return;
}
const token = localStorage.getItem(TOKEN_KEY);
const canDelete = credentials.length > 1;
list.innerHTML = credentials.map((c, i) => {
const name = c.label || 'Unknown device';
const isActive = !!c.lastUsed;
const statusClass = isActive ? 'active' : 'pending';
const statusText = isActive ? 'Active' : 'Not yet confirmed';
const created = c.createdAt ? new Date(c.createdAt).toLocaleDateString() : '';
const lastUsed = timeAgo(c.lastUsed);
return '<div class="device-item" id="device-' + i + '">' +
'<div class="device-item-header">' +
'<span class="device-name" id="device-name-' + i + '">' + escapeText(name) + '</span>' +
'<span class="device-status ' + statusClass + '">' + statusText + '</span>' +
'</div>' +
'<div class="device-meta">Created ' + created + ' &middot; Last used: ' + lastUsed + '</div>' +
'<div class="device-actions">' +
'<button onclick="renameDevice(' + i + ')" title="Rename">&#9998; Rename</button>' +
(canDelete ? '<button class="danger" onclick="deleteDevice(' + i + ')" title="Delete">&#128465; Delete</button>' : '') +
'</div>' +
'</div>';
}).join('');
}
function escapeText(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function renameDevice(idx) {
const creds = window._credentialsData;
if (!creds || !creds[idx]) return;
const cred = creds[idx];
const nameEl = document.getElementById('device-name-' + idx);
const currentName = cred.label || '';
nameEl.innerHTML = '<input class="device-rename-input" id="rename-input-' + idx + '" value="' + escapeText(currentName) + '" maxlength="100" />';
const input = document.getElementById('rename-input-' + idx);
input.focus();
input.select();
input.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') await saveRename(idx);
if (e.key === 'Escape') renderDeviceList(creds);
});
input.addEventListener('blur', () => saveRename(idx));
}
async function saveRename(idx) {
const input = document.getElementById('rename-input-' + idx);
if (!input) return;
const creds = window._credentialsData;
const cred = creds[idx];
const newLabel = input.value.trim();
if (!newLabel || newLabel === (cred.label || '')) {
renderDeviceList(creds);
return;
}
const token = localStorage.getItem(TOKEN_KEY);
try {
const res = await fetch('/api/user/credentials/' + encodeURIComponent(cred.credentialId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ label: newLabel }),
});
if (res.ok) {
cred.label = newLabel;
}
} catch { /* ignore */ }
renderDeviceList(creds);
}
async function deleteDevice(idx) {
const creds = window._credentialsData;
if (!creds || !creds[idx]) return;
if (creds.length <= 1) { alert('Cannot delete your only device'); return; }
const cred = creds[idx];
const name = cred.label || 'this device';
if (!confirm('Delete ' + name + '? This cannot be undone.')) return;
const token = localStorage.getItem(TOKEN_KEY);
try {
const res = await fetch('/api/user/credentials/' + encodeURIComponent(cred.credentialId), {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) {
creds.splice(idx, 1);
renderDeviceList(creds);
} else {
const data = await res.json().catch(() => ({}));
alert(data.error || 'Failed to delete device');
}
} catch { alert('Failed to delete device'); }
}
// ── Checklist update ── // ── Checklist update ──
function updateChecklist(guardians) { function updateChecklist(guardians) {
@ -8993,14 +9221,19 @@ app.get('/', (c) => {
emailIcon.className = 'check-icon done'; emailIcon.className = 'check-icon done';
emailIcon.innerHTML = '&#10003;'; emailIcon.innerHTML = '&#10003;';
} }
// Device check (from passkey count) // Device check — only count confirmed devices (those with lastUsed set)
const passkeyItems = document.querySelectorAll('.passkey-item'); const creds = window._credentialsData || [];
const confirmedCount = creds.filter(c => !!c.lastUsed).length;
const deviceIcon = document.getElementById('check-device'); const deviceIcon = document.getElementById('check-device');
const deviceText = document.getElementById('check-device-text'); const deviceText = document.getElementById('check-device-text');
if (passkeyItems.length > 1) { if (confirmedCount > 1) {
deviceIcon.className = 'check-icon done'; deviceIcon.className = 'check-icon done';
deviceIcon.innerHTML = '&#10003;'; deviceIcon.innerHTML = '&#10003;';
deviceText.textContent = passkeyItems.length + ' devices linked'; deviceText.textContent = confirmedCount + ' devices confirmed';
} else if (creds.length > 1) {
deviceIcon.className = 'check-icon todo';
deviceIcon.innerHTML = creds.length.toString();
deviceText.textContent = creds.length + ' devices linked (sign in on new device to confirm)';
} }
// Guardian check // Guardian check
const guardianIcon = document.getElementById('check-guardians'); const guardianIcon = document.getElementById('check-guardians');