Mail setup inside the app: admins save the Microsoft app ID, secret and expiry in Settings, with the redirect address to copy, an expiry warning, and plain-language Microsoft errors
build-container / build (push) Successful in 20m34s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 08:17:40 -04:00
1 parent 5c224521d3
commit 2d8d4f4d82
9 files changed
+175 -20

No files matched your search

+44 -1
View File
@@ -38,13 +38,56 @@ async function renderMailCard(el) {
}
function mailSettings() {
const box = $('#mailst'); if (!box) return;
jfetch('/api/mail/status').then(s => {
if (allowed('mail') && cfg.mail) jfetch('/api/mail/status').then(s => {
if (!$('#mailst')) return;
box.innerHTML = s.connected
? `<div class="row"><span class="grow"><b>Outlook.com</b><small>${esc(s.address || 'Connected')}</small></span><button class="btn danger" id="mdis" style="min-height:36px">Disconnect</button></div>`
: `<div class="row"><span class="grow"><b>Outlook.com</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Not connected</small></span><a class="btn ghost" href="#/mail" style="min-height:36px">Connect</a></div>`;
const d = $('#mdis'); if (d) d.onclick = () => confirmSheet('Disconnect mail?', 'NE Hub forgets your Outlook.com sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: '{}' }); mail.st = null; mailUnread = 0; renderNav(); mailSettings(); } catch (e) { toast(e.message); } });
}).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; });
else box.innerHTML = '';
if (cfg.mailAdmin) mailAdminCard();
}
/** Admin: the Microsoft app behind Mail (client ID, secret, expiry), with the redirect address to register and a test button. */
async function mailAdminCard() {
let host = $('#mailadm');
if (!host) { $('#sec-mail').insertAdjacentHTML('beforeend', '<div id="mailadm"></div>'); host = $('#mailadm'); }
let a; try { a = await jfetch('/api/mail/admin'); } catch (e) { host.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
if (!$('#mailadm')) return;
const warn = a.days === null ? '' : a.days < 0 ? `<p class="err" style="margin:8px 0 0">The client secret expired ${-a.days} day${a.days === -1 ? '' : 's'} ago. Mail cannot connect until you replace it.</p>`
: a.days <= 30 ? `<p class="err" style="margin:8px 0 0">The client secret expires in ${a.days} day${a.days === 1 ? '' : 's'} (${esc(a.secretExpires)}). Make a new one in Microsoft Entra and paste it below.</p>` : '';
host.innerHTML = `<h2>Microsoft app (admin)</h2>
<p class="sub">Mail uses a free Microsoft app registration. ${a.source === 'env' ? 'This server gets it from its environment settings (MS_CLIENT_ID), so change it there.' : a.source === 'app' ? 'Saved here, encrypted on your server.' : 'Not set up yet: follow the steps below, then paste the values here.'}</p>
${warn}
<p class="sub" style="margin:8px 0 0">${a.verifiedAt ? '✓ Last successful connection: ' + esc(new Date(a.verifiedAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })) : a.clientId ? 'Not proven yet. Open the Mail tab and press Connect: Microsoft checks everything then.' : ''}</p>
<div class="card" style="max-width:none">
<label>Redirect address to register with Microsoft</label>
<div style="display:flex;gap:8px"><input type="text" id="mredir" readonly value="${esc(a.redirect)}" style="flex:1"><button class="btn ghost" id="mcopy" type="button">Copy</button></div>
<p class="sub" style="margin:4px 0 0">It must match exactly, and be https (set PUBLIC_URL on the server if this shows the wrong address).</p>
${a.source === 'env' ? `<label>Application (client) ID</label><input type="text" readonly value="${esc(a.clientId)}"><p class="sub">Client secret: ${a.hasSecret ? 'set' : 'not set'}</p>` : `<form id="madm">
<label for="mcid">Application (client) ID</label><input type="text" id="mcid" value="${esc(a.clientId)}" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="1a2b3c4d-1234-5678-9abc-def012345678">
<label for="mcsec">Client secret (the Value)</label><input type="password" id="mcsec" autocomplete="new-password" placeholder="${a.hasSecret ? 'Saved. Leave blank to keep it' : 'Leave blank if the app has no secret'}">
<label for="mcexp">Secret expires on</label><input type="date" id="mcexp" value="${esc(a.secretExpires)}"><p class="sub" style="margin:4px 0 0">Microsoft secrets last at most 24 months. NE Hub warns you a month before.</p>
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div>
${a.source === 'app' ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="mclear" style="flex:1;justify-content:center">Remove these settings</button></div>' : ''}
<div id="mres" class="err" role="status"></div></form>`}
</div>
<details style="margin-top:10px"><summary style="cursor:pointer;color:var(--accent)">How to register the app (about 5 minutes)</summary>
<ol class="sub" style="line-height:1.55;padding-left:20px">
<li>Go to <b>entra.microsoft.com</b> and sign in with any Microsoft account. Accept the free "Default Directory" if asked.</li>
<li><b>Entra ID → App registrations → New registration</b>. Name it NE Hub, choose <b>Personal Microsoft accounts only</b>, set the redirect platform to <b>Web</b>, paste the redirect address above, press Register.</li>
<li>Copy the <b>Application (client) ID</b> from the Overview page into the first box above.</li>
<li><b>API permissions → Add a permission → Microsoft Graph → Delegated</b>: Mail.ReadWrite, Mail.Send, User.Read, offline_access.</li>
<li><b>Certificates &amp; secrets → New client secret</b> (24 months). Copy its <b>Value</b> now (shown once) into the secret box, and set the expiry date.</li>
<li>Press Save, then open the Mail tab and press <b>Connect</b>. Microsoft checks the ID, secret and redirect address at that moment; if something is wrong it says what.</li>
</ol></details>`;
$('#mcopy').onclick = async () => { try { await navigator.clipboard.writeText(a.redirect); toast('Copied'); } catch { $('#mredir').select(); toast('Press Ctrl/Cmd-C to copy'); } };
const f = $('#madm'); if (!f) return;
const res = (t, good) => { const el = $('#mres'); el.textContent = t; el.style.color = good ? 'var(--accent)' : ''; };
const save = async extra => jfetch('/api/mail/admin', { method: 'POST', body: JSON.stringify({ clientId: $('#mcid').value, clientSecret: $('#mcsec').value, secretExpires: $('#mcexp').value, ...(extra || {}) }) });
f.onsubmit = async e => { e.preventDefault(); res(''); try { await save(); const r = await jfetch('/api/config'); cfg = { ...cfg, ...r }; toast('Saved. Now open the Mail tab and press Connect.'); renderNav(); mailSettings(); } catch (err) { res(err.message); } };
if ($('#mclear')) $('#mclear').onclick = () => confirmSheet('Remove the Microsoft app settings?', 'Mail stops working for everyone until you set it up again. Connected mailboxes stay saved.', 'Remove', async () => { try { await jfetch('/api/mail/admin', { method: 'POST', body: JSON.stringify({ clientId: '', clearSecret: true, secretExpires: '' }) }); const c = await jfetch('/api/config'); cfg = { ...cfg, ...c }; renderNav(); mailSettings(); } catch (e) { toast(e.message); } });
}
// ---- the tab
+1 -1
View File
@@ -95,7 +95,7 @@ function viewSettings() {
remindersInit();
// only show what this person may use
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
hide(!(allowed('mail') && cfg.mail), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
hide(!((allowed('mail') && cfg.mail) || cfg.mailAdmin), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
if ($('#sga-s')) {