Two-step sign-in: authenticator app, security keys and passkeys

This commit is contained in:
Daniel Redetzke
2026-10-04 21:55:53 +03:00
parent 1e8175cad4
commit 3e44022b0b
11 changed files with 1590 additions and 41 deletions
+349 -6
View File
@@ -48,6 +48,7 @@
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
};
@@ -138,6 +139,16 @@
return ts + ' ' + String(l.level).padEnd(5) + ' ' + l.msg + (rest ? ' ' + rest : '');
}
// mfaText summarizes a user's two-step sign-in: "App, 2 keys" or "".
function mfaText(m) {
if (!m) return '';
const parts = [];
if (m.totp) parts.push('App');
if (m.keys) parts.push(m.keys === 1 ? '1 key' : m.keys + ' keys');
if (m.passkeys) parts.push(m.passkeys === 1 ? '1 passkey' : m.passkeys + ' passkeys');
return parts.join(', ');
}
// "Germany · Deutsche Telekom AG", "Local network" or "".
function fmtLocation(g) {
if (!g) return '';
@@ -216,7 +227,7 @@
const r = await fetch('/api/v1' + path, opt);
let data = {};
try { data = await r.json(); } catch { /* empty body */ }
if (r.status === 401 && path !== '/auth/login' && path !== '/auth/me') {
if (r.status === 401 && !path.startsWith('/auth/login') && path !== '/auth/me') {
me = null;
showLogin();
throw new Error('Signed out');
@@ -225,6 +236,10 @@
showNewPassword();
throw new Error('Signed out');
}
if (r.status === 403 && data.code === 'mfa_setup_required') {
showMFASetup();
throw new Error('Signed out');
}
if (!r.ok) throw new Error(data.error || r.statusText);
return data;
}
@@ -566,6 +581,7 @@
try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; }
}
if (me.mustChangePassword) { showNewPassword(); return; }
if (me.mfaSetupRequired) { showMFASetup(); return; }
if (!main || !main.isConnected) buildShell();
every(30000, refreshSide);
const hash = location.hash || '#/';
@@ -604,9 +620,9 @@
err.textContent = '';
btn.disabled = true;
try {
await api('POST', '/auth/login', { username: user.value, password: pw.value });
me = await api('GET', '/auth/me');
if (me.mustChangePassword) showNewPassword(pw.value); else render();
const res = await api('POST', '/auth/login', { username: user.value, password: pw.value });
if (res.mfa) { showSecondStep(res.ticket, res.methods, pw.value); return; }
await signedIn(pw.value);
} catch (x) {
err.textContent = x.message;
btn.disabled = false;
@@ -616,12 +632,305 @@
h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), user),
h('div', { class: 'field' }, h('label', { htmlFor: 'p' }, 'Password'), pw),
err, btn);
// A passkey signs in without username and password, where the address
// allows it.
const passkeyRow = h('div', { class: 'loginalt', hidden: true },
h('div', { class: 'or' }, 'or'),
h('button', { type: 'button', class: 'btn altbtn', onClick: async () => {
err.textContent = '';
try {
const b = await api('POST', '/auth/login/passkey/begin');
const cred = await webauthnGet(b.options);
await api('POST', '/auth/login/passkey/finish?id=' + encodeURIComponent(b.id), cred);
await signedIn();
} catch (x) { err.textContent = keyError(x); }
} }, icon('key', 18), 'Sign in with a passkey'));
if (window.PublicKeyCredential) {
api('GET', '/auth/options').then((o) => { passkeyRow.hidden = !o.passkeys; }).catch(() => {});
}
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' },
brand(72),
form)));
form, passkeyRow)));
user.focus();
}
// signedIn continues after a successful sign-in. password is the one just
// typed, if any, so a temporary password need not be typed again.
async function signedIn(password) {
me = await api('GET', '/auth/me');
if (me.mustChangePassword) showNewPassword(password); else render();
}
// ---------- two-step sign-in ----------
const b64dec = (s) => {
const b = atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - s.length % 4) % 4));
return Uint8Array.from(b, (c) => c.charCodeAt(0)).buffer;
};
const b64enc = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// webauthnCreate and webauthnGet turn the server's options into the
// browser call and the browser's answer back into JSON.
async function webauthnCreate(opts) {
const pk = opts.publicKey;
pk.challenge = b64dec(pk.challenge);
pk.user.id = b64dec(pk.user.id);
(pk.excludeCredentials || []).forEach((c) => { c.id = b64dec(c.id); });
const c = await navigator.credentials.create({ publicKey: pk });
return {
id: c.id, rawId: b64enc(c.rawId), type: c.type, authenticatorAttachment: c.authenticatorAttachment,
response: {
clientDataJSON: b64enc(c.response.clientDataJSON), attestationObject: b64enc(c.response.attestationObject),
transports: c.response.getTransports ? c.response.getTransports() : [],
},
clientExtensionResults: c.getClientExtensionResults(),
};
}
async function webauthnGet(opts) {
const pk = opts.publicKey;
pk.challenge = b64dec(pk.challenge);
(pk.allowCredentials || []).forEach((c) => { c.id = b64dec(c.id); });
const c = await navigator.credentials.get({ publicKey: pk });
return {
id: c.id, rawId: b64enc(c.rawId), type: c.type, authenticatorAttachment: c.authenticatorAttachment,
response: {
clientDataJSON: b64enc(c.response.clientDataJSON), authenticatorData: b64enc(c.response.authenticatorData),
signature: b64enc(c.response.signature), userHandle: c.response.userHandle ? b64enc(c.response.userHandle) : null,
},
clientExtensionResults: c.getClientExtensionResults(),
};
}
// keyError explains a failed key or passkey prompt.
function keyError(x) {
if (x && x.name === 'NotAllowedError') return 'Cancelled or timed out. Try again.';
if (x && x.name === 'InvalidStateError') return 'This key is already set up for your account.';
if (x && x.name === 'SecurityError') return 'Security keys need this site on its domain name with a trusted certificate.';
return x.message;
}
// showSecondStep asks for a key, an authenticator code or a recovery code
// after a correct password.
function showSecondStep(ticket, methods, password) {
cleanups.forEach((f) => f());
cleanups = [];
main = null;
const canKey = methods.includes('key') && !!window.PublicKeyCredential;
let mode = canKey ? 'key' : methods.includes('totp') ? 'totp' : 'recovery';
const box = h('div', { class: 'loginform' });
const TITLES = {
key: ['Use your security key', 'Insert your key and touch it, or use the passkey on this device.'],
totp: ['Enter the code', 'The 6-digit code from your authenticator app.'],
recovery: ['Use a recovery code', 'One of the codes you saved when you set up two-step sign-in. Each works once.'],
};
const LINKS = { key: 'Use a security key instead', totp: 'Use an authenticator code instead', recovery: 'Use a recovery code' };
const head = h('div', { class: 'logintext' });
const draw = () => {
const err = h('p', { class: 'err-text', role: 'alert' });
head.replaceChildren(h('h1', null, TITLES[mode][0]), h('p', null, TITLES[mode][1]));
const others = ['key', 'totp', 'recovery'].filter((m) => m !== mode && methods.includes(m) && (m !== 'key' || canKey))
.map((m) => h('button', { type: 'button', class: 'linkbtn', onClick: () => { mode = m; draw(); } }, LINKS[m]));
const foot = h('div', { class: 'loginlinks' }, others, h('button', { type: 'button', class: 'linkbtn', onClick: showLogin }, 'Start over'));
if (mode === 'key') {
const btn = h('button', { type: 'button', class: 'btn primary' }, 'Use security key');
const go = async () => {
err.textContent = '';
btn.disabled = true;
try {
const opts = await api('POST', '/auth/login/key/begin', { ticket });
const cred = await webauthnGet(opts);
await api('POST', '/auth/login/key/finish?ticket=' + encodeURIComponent(ticket), cred);
await signedIn(password);
} catch (x) { err.textContent = keyError(x); btn.disabled = false; }
};
btn.addEventListener('click', go);
box.replaceChildren(err, btn, foot);
btn.focus();
return;
}
const code = h('input', { id: 'mc', autocomplete: 'one-time-code', autocapitalize: 'none', required: true,
inputMode: mode === 'totp' ? 'numeric' : 'text', class: 'mono codeinput', placeholder: mode === 'totp' ? '123 456' : 'XXXX-XXXX' });
const btn = h('button', { type: 'submit', class: 'btn primary' }, 'Verify');
box.replaceChildren(h('form', { class: 'loginform', onSubmit: async (e) => {
e.preventDefault();
err.textContent = '';
btn.disabled = true;
try {
await api('POST', '/auth/login/' + mode, { ticket, code: code.value });
await signedIn(password);
} catch (x) {
err.textContent = x.message;
btn.disabled = false;
code.select();
}
} }, h('div', { class: 'field' }, h('label', { htmlFor: 'mc', class: 'sr' }, TITLES[mode][0]), code), err, btn), foot);
code.focus();
};
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' }, brand(72), head, box)));
draw();
}
// showMFASetup is the screen for a user who must set up two-step sign-in
// before doing anything else.
async function showMFASetup() {
cleanups.forEach((f) => f());
cleanups = [];
main = null;
let st = { keysAvailable: false };
try { st = await api('GET', '/auth/mfa'); } catch { /* offer the app only */ }
const done = async () => { me = await api('GET', '/auth/me'); render(); };
const keys = st.keysAvailable && window.PublicKeyCredential;
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' },
brand(72),
h('div', { class: 'logintext' },
h('h1', null, 'Set up two-step sign-in'),
h('p', null, 'This server asks for a second step after the password. Add one to continue.')),
h('div', { class: 'loginform' },
h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(done) }, 'Use an authenticator app'),
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addKey(false, done) }, 'Use a security key') : null,
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addKey(true, done) }, 'Use a passkey') : null,
h('div', { class: 'loginlinks' }, h('button', { type: 'button', class: 'linkbtn', onClick: logout }, 'Sign out'))))));
}
// recoveryDialog shows new recovery codes once.
function recoveryDialog(codes, onClose) {
const text = codes.join('\n');
const d = dialog((close) => h('div', { class: 'dlg' },
h('h2', null, 'Your recovery codes'),
h('p', null, 'If you lose your phone or key, each of these signs you in once. Store them somewhere safe, such as your password manager. They are not shown again.'),
h('pre', { class: 'code codes' }, text),
h('div', { class: 'actions' },
h('button', { type: 'button', class: 'btn', onClick: () => copy(text) }, 'Copy'),
h('button', { type: 'button', class: 'btn', onClick: () => download(APP.toLowerCase() + '-recovery-codes.txt', text + '\n') }, 'Download')),
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done'))));
if (onClose) d.addEventListener('close', onClose);
}
// afterAdd shows recovery codes when the method was the first one.
const afterAdd = (res, onDone) => {
if (res.recoveryCodes && res.recoveryCodes.length) recoveryDialog(res.recoveryCodes, onDone);
else if (onDone) onDone();
};
async function addTOTP(onDone) {
let s;
try { s = await api('POST', '/auth/mfa/totp/setup'); } catch (x) { toast(x.message, true); return; }
const code = h('input', { id: 'tc', class: 'mono', autocomplete: 'one-time-code', inputMode: 'numeric', placeholder: '123 456', required: true });
const e = h('p', { class: 'err-text', role: 'alert' });
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault();
e.textContent = '';
try {
const res = await api('POST', '/auth/mfa/totp/confirm', { code: code.value });
close();
toast('Authenticator app turned on');
afterAdd(res, onDone);
} catch (x) { e.textContent = x.message; code.select(); }
} },
h('h2', null, 'Add an authenticator app'),
h('div', { class: 'qrrow' },
h('img', { class: 'qr', src: s.qr, alt: 'QR code for the authenticator app' }),
h('div', { class: 'col' },
h('p', null, 'Scan the code with your authenticator app, for example 1Password, Google Authenticator or Authy. Or enter this key by hand:'),
h('code', { class: 'mono secret' }, s.secret.match(/.{1,4}/g).join(' ')),
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(s.secret) }, 'Copy key')))),
h('div', { class: 'field' }, h('label', { htmlFor: 'tc' }, 'Code from the app'), code),
e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Turn on'))));
code.focus();
}
// addKey adds a security key, or with passkey a passkey that also signs
// in without a password.
function addKey(passkey, onDone) {
const nm = h('input', { id: 'kn', value: passkey ? 'Passkey' : 'YubiKey', autocomplete: 'off', maxLength: 64 });
const e = h('p', { class: 'err-text', role: 'alert' });
const btn = h('button', { type: 'submit', class: 'btn primary' }, passkey ? 'Add passkey' : 'Add security key');
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault();
e.textContent = '';
btn.disabled = true;
try {
const opts = await api('POST', '/auth/mfa/keys/begin', { passkey });
const cred = await webauthnCreate(opts);
const res = await api('POST', '/auth/mfa/keys/finish?name=' + encodeURIComponent(nm.value.trim()), cred);
close();
toast((passkey ? 'Passkey' : 'Security key') + ' added');
afterAdd(res, onDone);
} catch (x) { e.textContent = keyError(x); btn.disabled = false; }
} },
h('h2', null, passkey ? 'Add a passkey' : 'Add a security key'),
h('p', null, passkey
? 'A passkey signs you in on its own, without username and password. It can live in your password manager, on this device (Touch ID, Face ID, Windows Hello) or on a YubiKey.'
: 'A YubiKey or other FIDO2 key, asked for after your password. Have it ready: your browser asks you to insert and touch it.'),
h('div', { class: 'field' }, h('label', { htmlFor: 'kn' }, 'Name'), nm, h('span', { class: 'hint' }, 'So you can tell your keys apart')),
e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), btn)));
nm.select();
}
// mfaCard is the "Two-step sign-in" section of My account.
function mfaCard() {
const body = h('div', null, h('p', { class: 'muted' }, 'Loading…'));
const card = h('section', { class: 'card', 'aria-labelledby': 'mfa' },
h('h2', { id: 'mfa' }, 'Two-step sign-in'),
h('p', { class: 'lead' }, 'Asks for a second proof after your password. App tokens, like the iOS app\'s, are not affected.'),
body);
const draw = async () => {
let s;
try { s = await api('GET', '/auth/mfa'); } catch (x) { body.replaceChildren(h('p', { class: 'err-text' }, x.message)); return; }
const keys = s.keysAvailable && window.PublicKeyCredential;
const removeKey = async (k) => {
if (!await confirmDialog({ title: 'Remove ' + k.name + '?', text: 'It can no longer be used to sign in.', ok: 'Remove', danger: true })) return;
try { await api('DELETE', '/auth/mfa/keys/' + k.id); toast('Removed ' + k.name); draw(); } catch (x) { toast(x.message, true); }
};
const renameKey = (k) => {
const nm = h('input', { id: 'rk', value: k.name, maxLength: 64, required: true });
const e = h('p', { class: 'err-text', role: 'alert' });
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault();
try { await api('PATCH', '/auth/mfa/keys/' + k.id, { name: nm.value.trim() }); close(); draw(); } catch (x) { e.textContent = x.message; }
} }, h('h2', null, 'Rename key'), h('div', { class: 'field' }, h('label', { htmlFor: 'rk' }, 'Name'), nm), e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
nm.select();
};
const removeTOTP = async () => {
if (!await confirmDialog({ title: 'Remove the authenticator app?', text: 'Its codes stop working for this account.', ok: 'Remove', danger: true })) return;
try { await api('DELETE', '/auth/mfa/totp'); toast('Authenticator app removed'); draw(); } catch (x) { toast(x.message, true); }
};
const newCodes = async () => {
if (!await confirmDialog({ title: 'Make new recovery codes?', text: 'Your old codes stop working.', ok: 'Make new codes' })) return;
try { recoveryDialog((await api('POST', '/auth/mfa/recovery-codes')).recoveryCodes, draw); } catch (x) { toast(x.message, true); }
};
const rows = [];
if (s.totp) {
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, 'Authenticator app'), h('div', { class: 'hint' }, 'Added ' + fmtDate(s.totpAdded))),
h('button', { type: 'button', class: 'btn danger small', onClick: removeTOTP }, 'Remove')));
}
for (const k of s.keys) {
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, k.name),
h('div', { class: 'hint' }, (k.passkey ? 'Passkey' : 'Security key') + ' · added ' + fmtDate(k.created) + ' · ' + (k.lastUsed ? 'last used ' + ago(k.lastUsed) : 'not used yet'))),
h('button', { type: 'button', class: 'btn small', onClick: () => renameKey(k) }, 'Rename'),
h('button', { type: 'button', class: 'btn danger small', onClick: () => removeKey(k) }, 'Remove')));
}
if (rows.length) {
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, 'Recovery codes'), h('div', { class: 'hint' }, s.recoveryLeft + ' of 10 left')),
h('button', { type: 'button', class: 'btn small', onClick: newCodes }, 'New codes')));
}
body.replaceChildren(...[
rows.length ? h('div', { class: 'mfalist' }, rows) : h('div', { class: 'notice' }, s.required ? 'Two-step sign-in is required on this server.' : 'Two-step sign-in is off for your account.'),
h('div', { class: 'actions section' },
s.totp ? null : h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(draw) }, 'Add authenticator app'),
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addKey(false, draw) }, 'Add security key') : null,
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addKey(true, draw) }, 'Add passkey') : null),
keys ? null : h('p', { class: 'hint section' }, 'Security keys and passkeys need this site on its domain name with a trusted certificate (Let\'s Encrypt or certificate files).'),
].filter(Boolean));
};
draw();
return card;
}
// showNewPassword is the screen after signing in with a temporary password
// an admin chose. current is that password when the user just typed it.
function showNewPassword(current) {
@@ -1407,6 +1716,8 @@
pwErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Change password'))),
mfaCard(),
h('section', { class: 'card flush', 'aria-labelledby': 'mytk' },
h('div', { class: 'cardhead' },
h('div', null, h('h2', { id: 'mytk' }, 'My app tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Tokens you created for the iOS app and scripts. All tokens are listed under Settings → API tokens.')),
@@ -1429,6 +1740,7 @@
const drawUsers = (users) => userBody.replaceChildren(...users.map((u) => h('tr', null,
h('td', null, h('strong', null, u.username), u.you ? h('span', { class: 'tag plain' }, 'You') : null, u.note ? h('div', { class: 'note' }, u.note) : null),
h('td', null, u.mustChangePassword ? h('span', { class: 'badge warn' }, 'Must choose a password') : h('span', { class: 'muted' }, 'Active')),
h('td', null, mfaText(u.mfa) ? h('span', { class: 'badge' }, mfaText(u.mfa)) : h('span', { class: s.signin.requireMfa ? 'badge warn' : 'muted' }, 'Off')),
h('td', null, u.lastLogin ? ago(u.lastLogin.at) + ' · ' + u.lastLogin.ip : h('span', { class: 'muted' }, 'Not since restart')),
h('td', null, u.tokens ? String(u.tokens) : h('span', { class: 'muted' }, 'None')),
h('td', null, fmtDate(u.created)),
@@ -1502,6 +1814,7 @@
must.el,
h('div', { class: 'actions' },
h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetUser(u); } }, 'Reset password…'),
mfaText(u.mfa) ? h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetMFA(u); } }, 'Reset two-step sign-in…') : null,
h('button', { type: 'button', class: 'btn danger', onClick: () => { close(); deleteUser(u); } }, 'Delete user…')),
e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
@@ -1525,6 +1838,10 @@
pw.el, must.el, e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Reset password'))));
};
const resetMFA = async (u) => {
if (!await confirmDialog({ title: 'Reset two-step sign-in for ' + u.username + '?', text: 'Their authenticator app, security keys, passkeys and recovery codes are removed. They sign in with their password and can set it up again.', ok: 'Reset', danger: true })) return;
try { await api('POST', '/users/' + u.id + '/reset-mfa'); toast('Two-step sign-in reset for ' + u.username); reloadUsers(); } catch (x) { toast(x.message, true); }
};
const deleteUser = async (u) => {
const tokens = u.tokens ? ' Their ' + (u.tokens === 1 ? 'app token is' : u.tokens + ' app tokens are') + ' revoked too.' : '';
if (!await confirmDialog({ title: 'Delete ' + u.username + '?', text: u.username + ' is signed out and can no longer sign in.' + tokens, ok: 'Delete user', danger: true })) return;
@@ -1596,6 +1913,26 @@
try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); }
} }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l)));
// sign-in rules
const requireBox = h('input', { type: 'checkbox', id: 'rq', checked: s.signin.requireMfa, onChange: async (e) => {
const on = e.target.checked;
if (on) {
const mine = us.users.find((u) => u.you);
const without = us.users.filter((u) => !mfaText(u.mfa)).map((u) => u.username);
const text = 'Users without two-step sign-in must set it up right after their next sign-in, before they can do anything else. API tokens are not affected.' +
(without.length ? ' Not set up yet: ' + without.join(', ') + '.' : '') +
(mine && !mfaText(mine.mfa) ? ' That includes you: you are asked to set it up now.' : '');
if (!await confirmDialog({ title: 'Require two-step sign-in?', text, ok: 'Require it' })) { e.target.checked = false; return; }
}
try {
await api('PATCH', '/settings', { signin: { ...s.signin, requireMfa: on } });
s.signin.requireMfa = on;
toast(on ? 'Two-step sign-in required' : 'Two-step sign-in optional');
me = await api('GET', '/auth/me');
if (me.mfaSetupRequired) showMFASetup(); else reloadUsers();
} catch (x) { e.target.checked = !on; toast(x.message, true); }
} });
// decoy
const decoyPages = [['nginx', 'nginx welcome page'], ['apache', 'Apache "It works!" page'], ['soon', '"Coming soon" page'], ['blank', 'Blank page'], ['forbidden', '"Forbidden" page'], ['private', '"Private server" page']];
const decoyBox = h('input', { type: 'checkbox', id: 'dc', checked: s.decoy.enabled, onChange: async (e) => {
@@ -1675,9 +2012,15 @@
h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')),
h('div', { class: 'tbl' }, h('table', null,
h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Two-step'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
userBody))),
h('section', { class: 'card', 'aria-labelledby': 'sgn' },
h('h2', { id: 'sgn' }, 'Sign-in'),
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app, security keys such as a YubiKey, or passkeys. Changes apply immediately.'),
h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'),
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.')))),
h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' },
h('h2', { id: 'web' }, 'Web interface'),
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'),