Username is required: blocking first step on every sign-in until one is saved

showOnboard(required) has no skip and no dismiss; a suggestion from the email
prefix is prefilled (GET /api/my/username-suggest, made unique); the member
area does not render until the username exists. Welcome tour and login ad still
run once for invite arrivals afterwards. Wallet-only sessions are unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-12 15:32:42 -05:00
parent 6996186061
commit a385fbf4c2
3 changed files with 35 additions and 10 deletions
+19 -5
View File
@@ -523,12 +523,18 @@
if ($('mcFinish')) $('mcFinish').hidden = !walletOnly; if ($('mcFinish')) $('mcFinish').hidden = !walletOnly;
if (!signedIn) return; if (!signedIn) return;
if ($('adminLink')) $('adminLink').hidden = !me.isAdmin; // admin portal link, only for ADMIN_EMAIL if ($('adminLink')) $('adminLink').hidden = !me.isAdmin; // admin portal link, only for ADMIN_EMAIL
// arrived from an invite page: run onboarding once (username, welcome tour, login ad) // REQUIRED first step (Marty, 2026-09-12): no username, nothing else. The modal cannot be
// skipped or dismissed; it resolves only when a username is saved, then the area renders.
if (!me.username) {
if (!render.gating) { render.gating = true; showOnboard(true).then(() => { render.gating = false; render(); }); }
return;
}
// arrived from an invite page: run the welcome tour and login ad once
if (/[?&]welcome=1/.test(location.search) && !render.welcomed) { if (/[?&]welcome=1/.test(location.search) && !render.welcomed) {
render.welcomed = true; render.welcomed = true;
history.replaceState(null, '', '/my' + (location.hash || '')); history.replaceState(null, '', '/my' + (location.hash || ''));
(async () => { (async () => {
try { if (!me.username) await showOnboard(); if (!(await showGauntlet())) await showLoginAd(); } catch (e) {} try { if (!(await showGauntlet())) await showLoginAd(); } catch (e) {}
await render(); await render();
})(); })();
} }
@@ -1771,15 +1777,23 @@
})); }));
})(); })();
// new-member onboarding: choose a username, optionally a bio (both skippable) // new-member onboarding: choose a username (required), optionally a bio.
function showOnboard() { // required=true: no skip, no backdrop dismiss, prefilled suggestion, resolves only after a save.
function showOnboard(required) {
return new Promise(resolve => { return new Promise(resolve => {
const m = $('onboardModal'); if (!m) return resolve(); const m = $('onboardModal'); if (!m) return resolve();
m.hidden = false; $('obErr').hidden = true; m.hidden = false; $('obErr').hidden = true;
$('obSkip').hidden = !!required;
if (required && !$('obUsername').value) {
fetch('/api/my/username-suggest').then(r => r.json()).then(r => { if (r.suggest && !$('obUsername').value) { $('obUsername').value = r.suggest; $('obUsername').select(); } }).catch(() => {});
}
setTimeout(() => $('obUsername').focus(), 50);
const done = () => { m.hidden = true; resolve(); }; const done = () => { m.hidden = true; resolve(); };
$('obSkip').onclick = done; $('obSkip').onclick = required ? null : done;
$('obUsername').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); $('obSave').click(); } };
$('obSave').onclick = async () => { $('obSave').onclick = async () => {
const u = $('obUsername').value.trim(); const u = $('obUsername').value.trim();
if (required && !u) { $('obErr').hidden = false; $('obErr').textContent = 'Pick a username to continue.'; return; }
try { try {
if (u) await api('/api/my/profile', { username: u }); if (u) await api('/api/my/profile', { username: u });
const bio = $('obBio').value.trim(); const bio = $('obBio').value.trim();
+5 -5
View File
@@ -98,15 +98,15 @@
<div id="onboardModal" class="modal-back" hidden> <div id="onboardModal" class="modal-back" hidden>
<div class="modal-card"> <div class="modal-card">
<p class="eyebrow">Welcome to InstantAdPay</p> <p class="eyebrow">Welcome to InstantAdPay</p>
<h3 style="margin:.2em 0 .5em">Pick your username</h3> <h3 style="margin:.2em 0 .5em">Pick your username to continue</h3>
<p class="muted small">This is how your team sees you, and it turns your invite link into a clean vanity link. <p class="muted small">This is how your team sees you, and it becomes your invite link and your public page.
You can change it later in Profile.</p> It is permanent once saved, so pick the one you want to be known by. We suggested one from your email; keep it or change it.</p>
<p><input id="obUsername" placeholder="Username (3-20 letters, numbers, _)" autocomplete="off" style="width:100%"></p> <p><input id="obUsername" placeholder="Username (3-20 letters, numbers, _)" autocomplete="off" style="width:100%"></p>
<p class="muted small" style="margin:12px 0 4px">Optional — a short bio for your public page (you can skip this).</p> <p class="muted small" style="margin:12px 0 4px">Optional — a short bio for your public page (you can skip this).</p>
<p><textarea id="obBio" rows="2" maxlength="600" placeholder="A short bio (optional)" style="width:100%"></textarea></p> <p><textarea id="obBio" rows="2" maxlength="600" placeholder="A short bio (optional)" style="width:100%"></textarea></p>
<p id="obErr" class="small" style="color:var(--bad)" hidden></p> <p id="obErr" class="small" style="color:var(--bad)" hidden></p>
<p style="margin-top:14px;display:flex;gap:10px;justify-content:flex-end"> <p style="margin-top:14px;display:flex;gap:10px;justify-content:flex-end">
<button class="btn sec small" id="obSkip" type="button">Skip for now</button> <button class="btn sec small" id="obSkip" type="button" hidden>Skip for now</button>
<button class="btn" id="obSave" type="button">Save &amp; continue</button> <button class="btn" id="obSave" type="button">Save &amp; continue</button>
</p> </p>
</div> </div>
@@ -915,7 +915,7 @@
<script src="/assets/common.js?v=20260912b"></script> <script src="/assets/common.js?v=20260912b"></script>
<script src="/assets/wallet.js?v=20260911a"></script> <script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script> <script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260912c"></script> <script src="/assets/my.js?v=20260912e"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
</body> </body>
</html> </html>
+11
View File
@@ -1108,6 +1108,17 @@ const server = http.createServer(async (req, res) => {
const r = await promos.setActive(b.code, !!b.active); const r = await promos.setActive(b.code, !!b.active);
return json(res, r.error ? 400 : 200, r); return json(res, r.error ? 400 : 200, r);
} }
// -- username suggestion for the required first step (Marty, 2026-09-12): email prefix, cleaned, unique
if (p === '/api/my/username-suggest' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
let base = String(s.email).split('@')[0].toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 18);
if (!/[a-z]/.test(base)) base = 'member' + base;
if (base.length < 3) base = (base + 'xyz').slice(0, 3);
let pick = base;
for (let i = 2; i < 100 && await accounts.byUsername(pick); i++) pick = base.slice(0, 20 - String(i).length) + i;
return json(res, 200, { suggest: pick });
}
if (p === '/api/my/tank' && req.method === 'GET') { if (p === '/api/my/tank' && req.method === 'GET') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });