Passkeys: one-time invite codes to enrol another device
build-container / build (push) Successful in 2m14s

A signed-in device creates a short-lived, single-use code in Settings
("Invite a device"); the new device enters it on the sign-in page to create
its own passkey. Codes are throttled like other secrets and cannot be used
by signed-out clients to create more codes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 14:56:19 -04:00
1 parent e8b46bb3aa
commit 6ec98d2527
4 files changed
+58 -12

No files matched your search

+9 -1
View File
@@ -779,7 +779,7 @@ function viewSettings() {
const signed = !!sf.token();
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<h2>Connections</h2><div class="list">
<div class="row"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
<div class="row"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
@@ -820,6 +820,14 @@ function viewSettings() {
$('#addpk').onclick = () => textSheet('Add a passkey', 'Name this device', 'Create', async name => {
try { await passkeyRegister('', name); toast('Passkey added'); loadPks(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Cancelled' : e.message); }
});
$('#invpk').onclick = async () => {
try {
const { code, minutes } = await authPost('/api/auth/invite');
const el = openSheet('Invite a device', 'On the new device, open this app, tap "New device? Use an invite code", and enter:', `<p style="font:700 34px ui-monospace,monospace;letter-spacing:.08em;text-align:center;margin:16px 0">${esc(code)}</p>
<p class="sub" style="text-align:center">Works once, and expires in ${minutes} minutes.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
} catch (e) { toast(e.message); }
};
$('#hubout').onclick = async () => { await authPost('/api/auth/logout'); location.replace('/login'); };
const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
}
+14 -1
View File
@@ -14,7 +14,20 @@ async function init() {
$('#sub').textContent = 'Sign in with your passkey';
$('#ui').innerHTML = '<button class="btn" id="go">Sign in with passkey</button>';
$('#go').onclick = signIn;
signIn(); // prompt straight away; the button is there if it's dismissed
$('#ui').insertAdjacentHTML('beforeend', '<p style="margin-top:18px"><a href="#" id="inv" style="color:var(--accent)">New device? Use an invite code</a></p>');
$('#inv').onclick = e => {
e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device';
$('#ui').innerHTML = `<form id="f"><p class="sub">On a device that is already signed in, open Settings and tap <b>Invite a device</b>.</p>
<label for="ic">Invite code</label><input type="text" id="ic" placeholder="ABCD-2345" autocomplete="off" autocapitalize="characters" required>
<label for="nm">Name this passkey</label><input type="text" id="nm" value="${/iPad|iPhone/.test(navigator.userAgent) ? 'iPhone/iPad' : 'This device'}" autocomplete="off">
<div class="bar"><button class="btn">Create passkey</button></div><p style="text-align:center"><a href="/login" style="color:var(--accent)">Back to sign in</a></p></form>`;
$('#f').onsubmit = async ev => {
ev.preventDefault(); $('#err').textContent = '';
try { await passkeyRegister('', $('#nm').value.trim() || 'Passkey', $('#ic').value.trim()); location.replace('/'); } catch (err) { fail(err); }
};
};
// Offer the passkey straight away unless the user came here to use an invite code.
signIn();
} else {
$('#sub').textContent = 'First-time setup: create a passkey for this device';
$('#ui').innerHTML = `<form id="f"><label for="sc">Setup code (from the server log or HUB_SETUP_CODE)</label>
+2 -2
View File
@@ -9,8 +9,8 @@ async function authPost(path, body, method = 'POST') {
if (!r.ok) throw new Error(j.error || 'Request failed (' + r.status + ')');
return j;
}
async function passkeyRegister(setupCode, name) {
const { cid, options } = await authPost('/api/auth/register/options', { setupCode });
async function passkeyRegister(setupCode, name, inviteCode) {
const { cid, options } = await authPost('/api/auth/register/options', { setupCode, inviteCode });
options.challenge = b64u.dec(options.challenge);
options.user.id = b64u.dec(options.user.id);
(options.excludeCredentials || []).forEach(c => { c.id = b64u.dec(c.id); });