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:
parent
195b42eb3b
commit
cede2232b5
|
|
@ -84,13 +84,13 @@ export async function getUserByUsername(username: string) {
|
|||
// 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)
|
||||
// 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 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 (
|
||||
${cred.credentialId},
|
||||
${cred.userId},
|
||||
|
|
@ -98,7 +98,8 @@ export async function storeCredential(cred: StoredCredential, did?: string): Pro
|
|||
${cred.counter},
|
||||
${cred.transports || null},
|
||||
${new Date(cred.createdAt)},
|
||||
${cred.rpId || 'rspace.online'}
|
||||
${cred.rpId || 'rspace.online'},
|
||||
${label || null}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -600,7 +600,7 @@ app.post('/api/register/complete', async (c) => {
|
|||
if (!body) {
|
||||
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) {
|
||||
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,
|
||||
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)
|
||||
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'));
|
||||
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) {
|
||||
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);
|
||||
|
||||
const rpId = resolveRpId(c);
|
||||
const devLabel = (typeof deviceName === 'string' && deviceName.trim()) ? deviceName.trim().slice(0, 100) : undefined;
|
||||
await storeCredential({
|
||||
credentialId: credential.credentialId,
|
||||
publicKey: credential.publicKey || '',
|
||||
|
|
@ -1900,7 +1902,7 @@ app.post('/api/account/device/complete', async (c) => {
|
|||
createdAt: Date.now(),
|
||||
transports: credential.transports || [],
|
||||
rpId,
|
||||
});
|
||||
}, undefined, devLabel);
|
||||
|
||||
console.log('EncryptID: Additional device registered for', user.username);
|
||||
return c.json({ success: true });
|
||||
|
|
@ -2336,6 +2338,25 @@ app.get('/recover', (c) => {
|
|||
</div>
|
||||
|
||||
<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 recoveryToken = params.get('token');
|
||||
let sessionToken = null;
|
||||
|
|
@ -2405,7 +2426,7 @@ app.get('/recover', (c) => {
|
|||
const completeRes = await fetch('/api/register/complete', {
|
||||
method: 'POST',
|
||||
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();
|
||||
if (result.success) {
|
||||
|
|
@ -2498,6 +2519,25 @@ app.get('/recover/social', (c) => {
|
|||
</div>
|
||||
|
||||
<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 requestId = params.get('id');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
|
@ -2616,7 +2656,7 @@ app.get('/recover/social', (c) => {
|
|||
const completeRes = await fetch('/api/register/complete', {
|
||||
method: 'POST',
|
||||
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();
|
||||
if (result.success) {
|
||||
|
|
@ -2894,6 +2934,25 @@ app.get('/guardian', (c) => {
|
|||
<script type="module">
|
||||
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 inviteToken = params.get('token');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
|
@ -2995,7 +3054,7 @@ app.get('/guardian', (c) => {
|
|||
const completeRes = await fetch('/api/register/complete', {
|
||||
method: 'POST',
|
||||
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();
|
||||
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 (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) {
|
||||
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
|
||||
const rpId = resolveRpId(c);
|
||||
const linkLabel = (typeof deviceName === 'string' && deviceName.trim()) ? deviceName.trim().slice(0, 100) : undefined;
|
||||
await storeCredential({
|
||||
credentialId: credential.credentialId,
|
||||
publicKey: credential.publicKey,
|
||||
|
|
@ -3681,7 +3741,7 @@ app.post('/api/device-link/:token/complete', async (c) => {
|
|||
createdAt: Date.now(),
|
||||
transports: credential.transports,
|
||||
rpId,
|
||||
});
|
||||
}, undefined, linkLabel);
|
||||
|
||||
await markDeviceLinkUsed(token);
|
||||
|
||||
|
|
@ -3737,6 +3797,25 @@ app.get('/link', (c) => {
|
|||
</div>
|
||||
|
||||
<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 linkToken = params.get('token');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
|
@ -3834,7 +3913,7 @@ app.get('/link', (c) => {
|
|||
const completeRes = await fetch('/api/device-link/' + encodeURIComponent(linkToken) + '/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: credentialData }),
|
||||
body: JSON.stringify({ credential: credentialData, deviceName: detectDeviceName() }),
|
||||
});
|
||||
const data = await completeRes.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
|
@ -6130,6 +6209,25 @@ function joinPage(token: string): string {
|
|||
const TOKEN = ${JSON.stringify(token)};
|
||||
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 successEl = document.getElementById('success');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
|
@ -6255,6 +6353,7 @@ function joinPage(token: string): string {
|
|||
body: JSON.stringify({
|
||||
username,
|
||||
challenge: options.challenge,
|
||||
deviceName: detectDeviceName(),
|
||||
credential: {
|
||||
credentialId,
|
||||
attestationObject,
|
||||
|
|
@ -6595,6 +6694,7 @@ function oidcAcceptPage(token: string): string {
|
|||
body: JSON.stringify({
|
||||
username,
|
||||
challenge: options.challenge,
|
||||
deviceName: detectDeviceName(),
|
||||
credential: {
|
||||
credentialId: toB64url(credential.rawId),
|
||||
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:hover { background: rgba(239,68,68,0.15); }
|
||||
|
||||
/* Passkeys list */
|
||||
.passkeys { margin-top: 1.5rem; }
|
||||
.passkeys h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
|
||||
.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; }
|
||||
.passkey-id { font-family: monospace; color: #94a3b8; }
|
||||
.passkey-date { color: #64748b; }
|
||||
/* Device list */
|
||||
.device-item { padding: 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.8rem; }
|
||||
.device-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem; }
|
||||
.device-name { color: #e2e8f0; font-weight: 500; }
|
||||
.device-status { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 9999px; }
|
||||
.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-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">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">
|
||||
<h4>Account Security</h4>
|
||||
<div class="check-item"><div class="check-icon done">✓</div> Passkey created</div>
|
||||
|
|
@ -7958,6 +8060,25 @@ app.get('/', (c) => {
|
|||
authenticatePasskey,
|
||||
} 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 KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts';
|
||||
|
||||
|
|
@ -8182,6 +8303,7 @@ app.get('/', (c) => {
|
|||
credential: credentialData,
|
||||
userId, username,
|
||||
};
|
||||
regBody.deviceName = detectDeviceName();
|
||||
if (email) regBody.email = email;
|
||||
if (clientDid) regBody.clientDid = clientDid;
|
||||
if (eoaAddress) regBody.eoaAddress = eoaAddress;
|
||||
|
|
@ -8536,22 +8658,14 @@ app.get('/', (c) => {
|
|||
document.getElementById('profile-expires').textContent = exp.toLocaleString();
|
||||
} catch { document.getElementById('profile-expires').textContent = '--'; }
|
||||
|
||||
// Fetch passkeys
|
||||
// Fetch and render unified device list
|
||||
try {
|
||||
const res = await fetch('/api/user/credentials', {
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
});
|
||||
const data = await res.json();
|
||||
const list = document.getElementById('passkey-list');
|
||||
if (data.credentials && data.credentials.length > 0) {
|
||||
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>';
|
||||
}
|
||||
window._credentialsData = data.credentials || [];
|
||||
renderDeviceList(data.credentials || []);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// 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 + ' · Last used: ' + lastUsed + '</div>' +
|
||||
'<div class="device-actions">' +
|
||||
'<button onclick="renameDevice(' + i + ')" title="Rename">✎ Rename</button>' +
|
||||
(canDelete ? '<button class="danger" onclick="deleteDevice(' + i + ')" title="Delete">🗑 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 ──
|
||||
|
||||
function updateChecklist(guardians) {
|
||||
|
|
@ -8993,14 +9221,19 @@ app.get('/', (c) => {
|
|||
emailIcon.className = 'check-icon done';
|
||||
emailIcon.innerHTML = '✓';
|
||||
}
|
||||
// Device check (from passkey count)
|
||||
const passkeyItems = document.querySelectorAll('.passkey-item');
|
||||
// Device check — only count confirmed devices (those with lastUsed set)
|
||||
const creds = window._credentialsData || [];
|
||||
const confirmedCount = creds.filter(c => !!c.lastUsed).length;
|
||||
const deviceIcon = document.getElementById('check-device');
|
||||
const deviceText = document.getElementById('check-device-text');
|
||||
if (passkeyItems.length > 1) {
|
||||
if (confirmedCount > 1) {
|
||||
deviceIcon.className = 'check-icon done';
|
||||
deviceIcon.innerHTML = '✓';
|
||||
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
|
||||
const guardianIcon = document.getElementById('check-guardians');
|
||||
|
|
|
|||
Loading…
Reference in New Issue