Admin: Gmail and Discord-bot settings for password reset, setup warning on open; export notes admin guard

This commit is contained in:
Jonathan Sykes
2026-10-15 19:02:53 +08:00
parent 69704d3e1a
commit 63cea89b68
5 changed files with 232 additions and 7 deletions

View File

@@ -455,6 +455,19 @@
.tabs .tab { flex: none; }
}
.navbar { display: none; }
.notify-warn { display: flex; align-items: center; gap: 12px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--warn) 55%, transparent); border-radius: 14px; background: color-mix(in srgb, var(--warn) 10%, var(--bg-1)); }
.notify-warn.hidden { display: none; }
.notify-warn > svg { flex: none; width: 22px; height: 22px; fill: none; stroke: var(--warn); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.notify-warn p { flex: 1; margin: 0; font-size: 14px; color: var(--text); }
.notify-warn .iconbtn { flex: none; width: 44px; height: 44px; border: 0; background: transparent; color: var(--text-2); }
.notify-warn .iconbtn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.notify-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); gap: 16px; }
.notify-block { display: grid; gap: 10px; align-content: start; padding: 14px; border: 1px solid var(--line); border-radius: 14px; }
.notify-block h3 { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 15px; }
.nstate { font: 700 11px var(--ui); padding: 3px 9px; border-radius: 99px; border: 1px solid var(--line-2); color: var(--text-dim); }
.nstate.on { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); }
.nstate.off { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
@media (max-width: 760px) { .notify-warn { flex-wrap: wrap; } .notify-warn p { flex-basis: calc(100% - 90px); } }
.song-info { min-width: 0; flex: 1; }
.song-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.song-edit { display: none; }
@@ -578,6 +591,12 @@
</header>
<main>
<div id="notifyWarn" class="notify-warn hidden" role="status">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 2 20h20L12 3z"/><path d="M12 10v4M12 17.5v.01"/></svg>
<p id="notifyWarnText"></p>
<button class="btn sm" type="button" id="notifyWarnGo">Set up</button>
<button class="iconbtn" type="button" id="notifyWarnClose" aria-label="Dismiss"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</div>
<!-- ================= EDITOR ================= -->
<section id="p-editor" class="panel">
<div id="pickBox">
@@ -931,6 +950,29 @@
<section id="p-access" class="panel">
<h1 class="page">API access</h1>
<p class="lead">Tokens let scripts write shared lyrics and chapters. A token is shown once — store it in your secrets vault.</p>
<div class="card" id="notifyCard">
<h2>Password-reset senders</h2>
<p class="muted">Protected profiles can get a reset code by email, Discord DM or a Discord webhook. Webhooks need nothing here; email and DMs need these.</p>
<div class="notify-grid">
<form id="smtpForm" class="notify-block" autocomplete="off">
<h3>Gmail <span class="nstate" id="smtpState"></span></h3>
<label class="fl"><span>Sending Gmail address</span><input class="field" id="smtpUser" type="email" placeholder="sender@gmail.com" /></label>
<label class="fl"><span>App password</span><input class="field" id="smtpPass" type="password" autocomplete="new-password" placeholder="16 letters from myaccount.google.com/apppasswords" /></label>
<label class="fl"><span>From name (optional)</span><input class="field" id="smtpFrom" type="text" placeholder="YT Player &lt;sender@gmail.com&gt;" /></label>
<div class="row"><button class="btn pri" type="submit">Save Gmail</button>
<input class="field" id="smtpTestTo" type="email" placeholder="Send a test to…" style="flex:1 1 160px" /><button class="btn" type="button" id="smtpTest">Test</button></div>
<div class="msg" id="smtpMsg" role="status"></div>
</form>
<form id="botForm" class="notify-block" autocomplete="off">
<h3>Discord bot <span class="nstate" id="botState"></span></h3>
<label class="fl"><span>Bot token</span><input class="field" id="botToken" type="password" autocomplete="off" placeholder="discord.com/developers → your app → Bot → Reset Token" /></label>
<p class="muted" style="margin:0;font-size:13px">The token goes straight to the relay on the VPS and is never stored or shown here. Invite the bot to the server your users are in — Discord only delivers its DMs to people who share a server with it.</p>
<div class="row"><button class="btn pri" type="submit">Save bot token</button>
<input class="field" id="botTestTo" inputmode="numeric" placeholder="Discord user id for a test" style="flex:1 1 160px" /><button class="btn" type="button" id="botTest">Test</button></div>
<div class="msg" id="botMsg" role="status"></div>
</form>
</div>
</div>
<div class="card">
<h2>Tokens</h2>
<form id="tokenForm" class="row" style="margin-top:10px">
@@ -975,6 +1017,63 @@
return m ? m[1] : null;
}
/* ---------------- password-reset senders ---------------- */
let NOTIFY = null;
function paintNotify() {
if (!NOTIFY || !NOTIFY.email || !NOTIFY.discord) return;
const e = NOTIFY.email, d = NOTIFY.discord;
const pill = (el, on, text) => { el.textContent = text; el.className = 'nstate ' + (on ? 'on' : 'off'); };
pill($('smtpState'), e.configured, e.configured ? (e.source === 'environment' ? 'set in Dokploy' : 'on') : 'not set');
pill($('botState'), d.ready, d.ready ? `on · ${d.bot}` : d.botSet ? 'connecting' : d.relay ? 'not set' : 'relay missing');
if (document.activeElement !== $('smtpUser')) $('smtpUser').value = e.user || '';
if (document.activeElement !== $('smtpFrom')) $('smtpFrom').value = e.from || '';
$('smtpPass').placeholder = e.passwordSet ? 'Saved — type to replace' : '16 letters from myaccount.google.com/apppasswords';
for (const id of ['smtpUser', 'smtpPass', 'smtpFrom']) $(id).disabled = e.source === 'environment';
$('botMsg').textContent = d.error && !d.ready ? d.error : '';
const missing = [!e.configured && 'Gmail', !d.ready && 'the Discord bot'].filter(Boolean);
let dismissed = false; try { dismissed = sessionStorage.getItem('ytpNotifyWarn') === missing.join(); } catch { /* blocked */ }
$('notifyWarn').classList.toggle('hidden', !missing.length || dismissed);
$('notifyWarnText').textContent = missing.length
? `Password reset can only use Discord webhooks right now: ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not set up yet.` : '';
}
async function loadNotify() {
try { const j = await api('/api/admin/notify'); if (j.ok) { NOTIFY = j; paintNotify(); } } catch { /* offline */ }
}
async function saveNotify(body, msgEl) {
msgEl.className = 'msg'; msgEl.textContent = 'Saving…';
try {
const j = await api('/api/admin/notify', { method: 'PUT', body });
if (!j.ok) throw new Error(j.error || 'Could not save.');
NOTIFY = { ...NOTIFY, ...j }; paintNotify(); msgEl.className = 'msg ok'; msgEl.textContent = 'Saved.'; return true;
} catch (err) { msgEl.className = 'msg err'; msgEl.textContent = err.message; return false; }
}
$('smtpForm').addEventListener('submit', async (e) => {
e.preventDefault();
const body = { smtpUser: $('smtpUser').value.trim(), smtpFrom: $('smtpFrom').value.trim() };
if ($('smtpPass').value.trim()) body.smtpPass = $('smtpPass').value;
if (await saveNotify(body, $('smtpMsg'))) $('smtpPass').value = '';
});
$('botForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!$('botToken').value.trim()) { $('botMsg').className = 'msg err'; $('botMsg').textContent = 'Paste the bot token first.'; return; }
if (await saveNotify({ discordBotToken: $('botToken').value.trim() }, $('botMsg'))) $('botToken').value = '';
});
async function testNotify(via, to, msgEl) {
msgEl.className = 'msg'; msgEl.textContent = 'Sending…';
try { const j = await api('/api/admin/notify/test', { method: 'POST', body: { via, to } });
if (!j.ok) throw new Error(j.error || 'Could not send.'); msgEl.className = 'msg ok'; msgEl.textContent = 'Sent — check it arrived.';
} catch (err) { msgEl.className = 'msg err'; msgEl.textContent = err.message; }
}
$('smtpTest').addEventListener('click', () => testNotify('email', $('smtpTestTo').value.trim(), $('smtpMsg')));
$('botTest').addEventListener('click', () => testNotify('discord', $('botTestTo').value.trim(), $('botMsg')));
$('notifyWarnGo').addEventListener('click', () => { showTab('access'); setTimeout(() => $('notifyCard').scrollIntoView({ block: 'start' }), 50); });
$('notifyWarnClose').addEventListener('click', () => {
if (!NOTIFY || !NOTIFY.email) return;
const missing = [!NOTIFY.email.configured && 'Gmail', !NOTIFY.discord.ready && 'the Discord bot'].filter(Boolean);
try { sessionStorage.setItem('ytpNotifyWarn', missing.join()); } catch { /* blocked */ }
$('notifyWarn').classList.add('hidden');
});
/* ---------------- tabs ---------------- */
const ICONS = {
editor: '<path d="M4 20h4L19 9a2.8 2.8 0 00-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>',
@@ -1044,7 +1143,7 @@
if (name === 'backups') loadBackups();
if (name === 'peers') loadP2p();
if (name === 'library') { loadUploads(); loadBulkLibrary(); loadBulkJobs(); }
if (name === 'access') loadTokens();
if (name === 'access') { loadTokens(); loadNotify(); }
if (name === 'analytics') loadAnalytics();
if (name === 'ops') loadOpsMetrics(); else clearTimeout(opsTimer);
if (name === 'devices') loadDevices();
@@ -1075,6 +1174,7 @@
loadRecent();
loadSongs();
refreshBadge();
loadNotify();
const want = videoIdFrom(new URLSearchParams(location.search).get('v') || '');
const hash = location.hash.replace('#', '');
showTab(want ? 'editor' : (hash || 'editor'), false);