Protected profiles: recovery email and Discord contacts with reset codes (Gmail SMTP, Discord relay)

This commit is contained in:
Jonathan Sykes
2026-10-15 17:28:10 +08:00
parent dbcee5d5f8
commit ca47528581
8 changed files with 477 additions and 6 deletions

View File

@@ -868,6 +868,7 @@ function askProfileSecret(name, kind, wrong) {
showModal('🔒 Protected profile', body, [
{ label: 'Cancel', onClick: () => done(null) },
{ label: 'Import file', onClick: () => { closeModal(); resolve(null); importProfileCredentials(); } },
{ label: 'Forgot?', onClick: async () => { closeModal(); resolve(await resetProfileSecretFlow(name)); } },
{ label: 'Open', primary: true, onClick: () => { const v = ($('profileSecretInput').value || '').trim(); if (v) done(v); } },
]);
setTimeout(() => $('profileSecretInput') && $('profileSecretInput').focus(), 50);
@@ -1500,7 +1501,8 @@ function protectProfileFlow() {
${prot ? 'Change or remove the protection on' : 'Protect'} <strong>${escapeHtml(data.profile.name)}</strong>.
Once protected, the name alone no longer opens it — a device also needs the
password, the profile key, or the credentials file. <strong>If you lose all of
them the profile can't be opened.</strong>
them the profile can't be opened</strong> — unless you add a recovery email or
Discord under Recovery… afterwards.
</p>
<input id="profilePwInput" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" />
<input id="profilePwInput2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`;
@@ -1525,6 +1527,7 @@ function protectProfileFlow() {
} catch (e) { toast('⚠ ' + e.message); }
} },
];
if (prot) actions.splice(1, 0, { label: 'Recovery…', onClick: () => { closeModal(); recoveryContactsFlow(); } });
if (prot) actions.splice(1, 0, { label: 'Remove', danger: true, onClick: async () => {
try { await setProfileSecretOnServer(null, null); closeModal(); toast('Protection removed — the name alone opens this profile again'); }
catch (e) { toast('⚠ ' + e.message); }
@@ -1533,6 +1536,98 @@ function protectProfileFlow() {
setTimeout(() => $('profilePwInput') && $('profilePwInput').focus(), 50);
}
// ---- Password reset: recovery email / Discord ----
// The server sends a one-time code to the contact saved here; the code plus a
// new password replaces the profile's secret (server/profile-recovery.js).
async function recoveryContactsFlow() {
const name = data.profile && data.profile.name;
if (!name || !data.profile.protected) return;
let cur;
try {
const res = await fetch(`/api/profile/recovery?name=${encodeURIComponent(name)}`, { headers: profileHeaders(name) });
cur = await res.json();
if (!res.ok || !cur.ok) throw new Error(cur.error || 'could not load the recovery contacts');
} catch (e) { toast('⚠ ' + e.message); return; }
const off = (k) => (cur.available && cur.available[k]) ? '' : ' <em style="color:var(--text-dim)">(not switched on for this server yet)</em>';
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
If you forget the password of <strong>${escapeHtml(name)}</strong>, a reset code can be sent to one of these.
</p>
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Email${off('email')}</label>
<input id="recEmail" type="email" autocomplete="email" placeholder="you@example.com" value="${escapeHtml(cur.email || '')}" style="margin-bottom:12px" />
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Discord user id${off('discord')}</label>
<input id="recDiscord" inputmode="numeric" autocomplete="off" placeholder="e.g. 123456789012345678" value="${escapeHtml(cur.discord || '')}" />
<p style="margin:8px 0 0;color:var(--text-dim);font-size:12px;line-height:1.5">
Discord: Settings → Advanced → Developer Mode, then tap your name → Copy User ID. You must share a server with the YT Player bot for its message to reach you.
</p>`;
showModal('🛟 Recovery contacts', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Save', primary: true, onClick: async () => {
try {
const res = await fetch('/api/profile/recovery', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, current: ProfileSecret.getFor(name), email: $('recEmail').value.trim(), discord: $('recDiscord').value.trim() }) });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || 'could not save');
closeModal(); toast(j.email || j.discord ? 'Recovery contacts saved ✓' : 'Recovery contacts removed');
} catch (e) { toast('⚠ ' + e.message); }
} },
]);
}
// Forgot password: pick where the code goes, then enter it with a new password.
// Resolves the new password (so the caller can open the profile) or null.
async function resetProfileSecretFlow(name) {
let info = null;
try { info = await (await fetch(`/api/profile/info?name=${encodeURIComponent(name)}`)).json(); } catch { /* offline */ }
const rec = info && info.recovery;
if (!rec || (!rec.email && !rec.discord)) {
toast(`⚠ ${name} has no recovery email or Discord set — it opens only with its password, key or credentials file`, { duration: 6000 });
return null;
}
const via = await new Promise((resolve) => {
const body = document.createElement('div');
body.innerHTML = `<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
Send a reset code for <strong>${escapeHtml(name)}</strong>. It is valid for 15 minutes.</p>`;
showModal('🛟 Reset password', body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
...(rec.discord ? [{ label: 'Discord DM', primary: !rec.email, onClick: () => { closeModal(); resolve('discord'); } }] : []),
...(rec.email ? [{ label: `Email ${rec.emailHint || ''}`.trim(), primary: true, onClick: () => { closeModal(); resolve('email'); } }] : []),
]);
});
if (!via) return null;
try {
const res = await fetch('/api/profile/reset/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, via }) });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || 'could not send the code');
toast(`Code sent to ${j.hint} ✓`, { duration: 4000 });
} catch (e) { toast('⚠ ' + e.message, { duration: 6000 }); return null; }
return new Promise((resolve) => {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">Enter the 8-digit code and a new password for <strong>${escapeHtml(name)}</strong>.</p>
<input id="resetCode" inputmode="numeric" autocomplete="one-time-code" placeholder="1234-5678" style="margin-bottom:8px" />
<input id="resetPw" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" />
<input id="resetPw2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`;
showModal('🛟 Reset password', body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
{ label: 'Set password', primary: true, onClick: async () => {
const code = $('resetCode').value, a = $('resetPw').value, b = $('resetPw2').value;
if (a.length < 8) { toast('⚠ Use at least 8 characters'); return; }
if (a !== b) { toast('⚠ The two passwords differ'); return; }
try {
const res = await fetch('/api/profile/reset/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, code, secret: a }) });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || 'could not reset');
ProfileSecret.set(name, a, 'password');
closeModal(); toast('Password changed ✓', { duration: 4000 }); resolve(a);
} catch (e) { toast('⚠ ' + e.message, { duration: 5000 }); }
} },
]);
setTimeout(() => $('resetCode') && $('resetCode').focus(), 50);
});
}
// A small JSON file that signs another device into this profile.
async function exportProfileCredentials() {
const name = data.profile && data.profile.name;