Protected profiles: recovery email and Discord contacts with reset codes (Gmail SMTP, Discord relay)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user