Files
rm-circle-team-router/public/profile-gate.js
T
martbost 7d07fc01f1 Required member profile: username + verified email per position, gated at proof of ownership
Marty, 2026-09-16: leaders can write but 94% of positions cannot receive (47 of 771 have ever signed
in to messaging, 482 messages sit 85% unread). profiles.js stores username + verified email per
POSITION (one wallet holds one position, so a Triple Play holder has three; the person is the email
and one email may hold several positions). Seeds the 40 emails already on file from
member-alerts.json, pre-filled but unverified so confirming costs one tap.

Writes are only ever accepted from a session that PROVED ownership: wallet personal_sign
(messages.verifyChallenge) or the Telegram Mini App bridge. The public /my/<id> page is untouched and
cannot write a profile, verified by test: all four endpoints 401 unauthenticated while /my/21 stays
200. Endpoints GET /api/public/profile, POST .../username, .../email-start, .../email-verify, plus
GET /api/admin/profiles for coverage. Email codes: 6 digits, 15 min, 60s cooldown, 5/day, 6 tries.

profile-gate.js is a two-step modal that cannot be dismissed, fired on dashboard boot (covers the
Mini App landing) and right after a wallet sign-in. Chatbot canned answer + AI prompt updated.
28 unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:56:35 -05:00

180 lines
9.3 KiB
JavaScript

// RM Circle: required member profile gate (Marty, 2026-09-16).
//
// The member area only opens once a position has a username and a VERIFIED email.
// It fires the moment ownership is proved (wallet personal_sign, or the Telegram
// Mini App bridge) and cannot be dismissed, because the whole point is that a
// leader can reach every member. The public /my/<id> page is untouched: anyone
// can still read the chain data there, and nobody can write a profile from it.
//
// Usage: await window.RMCProfile.require(); // resolves once the profile is complete
(function () {
'use strict';
var back = null, resolveDone = null, state = null;
var GOLD = '#d4af37', TEAL = '#4ed6cb';
function el(tag, css, html) {
var e = document.createElement(tag);
if (css) e.style.cssText = css;
if (html != null) e.innerHTML = html;
return e;
}
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); }
async function api(path, body) {
var r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
var j = null; try { j = await r.json(); } catch (e) { j = {}; }
if (!r.ok || j.error) throw new Error(j.error || 'Something went wrong. Try again.');
return j;
}
function shell() {
back = el('div', 'position:fixed;inset:0;z-index:2147483100;display:flex;align-items:center;justify-content:center;' +
'padding:20px;background:rgba(3,7,14,.88);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);overflow:auto;');
back.setAttribute('role', 'dialog');
back.setAttribute('aria-label', 'Finish setting up your member profile');
var card = el('div', 'position:relative;width:100%;max-width:440px;max-height:94vh;overflow:auto;border-radius:20px;' +
'background:#0a1119;border:1px solid rgba(212,175,55,.55);box-shadow:0 24px 70px rgba(0,0,0,.7);' +
'font-family:system-ui,Segoe UI,Arial,sans-serif;color:#e8eef6;padding:22px 22px 20px;');
card.id = 'pgCard';
back.appendChild(card);
document.body.appendChild(back);
return card;
}
function head(card, step) {
card.innerHTML = '';
card.appendChild(el('div', 'text-align:center;letter-spacing:2px;text-transform:uppercase;font-size:11px;font-weight:700;color:' + GOLD + ';margin-bottom:8px;',
'Position #' + esc(state.id) + ' · step ' + step + ' of 2'));
return card;
}
function note(text, color) {
return el('p', 'margin:0 0 14px;font-size:13.5px;line-height:1.6;color:' + (color || '#9fb3c8') + ';', text);
}
function input(id, placeholder, value, type) {
var i = el('input');
i.id = id; i.type = type || 'text'; i.placeholder = placeholder; i.value = value || '';
i.autocomplete = type === 'email' ? 'email' : 'off';
i.style.cssText = 'width:100%;box-sizing:border-box;padding:12px 14px;border-radius:11px;border:1px solid rgba(255,255,255,.16);' +
'background:rgba(255,255,255,.04);color:#fff;font-size:16px;margin-bottom:10px;';
return i;
}
function button(label) {
var b = el('button', 'width:100%;padding:13px 16px;border-radius:11px;border:none;cursor:pointer;font-size:15px;font-weight:700;' +
'background:linear-gradient(180deg,' + GOLD + ',#b8932f);color:#1a1205;', label);
return b;
}
function errLine() { return el('p', 'margin:0 0 10px;font-size:13px;color:#ff8b8b;display:none;'); }
// ---- step 1: username ----
function stepUsername() {
var card = head(document.getElementById('pgCard'), 1);
card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Pick your username'));
card.appendChild(note('This is how your team leader and the people in your line see you, instead of a bare member number. Letters, numbers or underscores, 3 to 20 characters.'));
var err = errLine(); card.appendChild(err);
var i = input('pgUser', 'e.g. ' + (state.suggest || 'member' + state.id), (state.profile && state.profile.username) || '');
card.appendChild(i);
var b = button('Save and continue');
card.appendChild(b);
var go = async function () {
err.style.display = 'none'; b.disabled = true; b.textContent = 'Saving…';
try {
var r = await api('/api/public/profile/username', { username: i.value });
state.profile = r.profile;
next();
} catch (e) {
err.textContent = e.message; err.style.display = 'block'; b.disabled = false; b.textContent = 'Save and continue';
}
};
b.addEventListener('click', go);
i.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } });
setTimeout(function () { i.focus(); }, 60);
}
// ---- step 2: email + code ----
function stepEmail() {
var card = head(document.getElementById('pgCard'), 2);
var prefill = (state.profile && state.profile.email) || '';
card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Confirm your email'));
card.appendChild(note('Two reasons this is required. Your leader can actually reach you, and you get a note the moment a payout lands in your wallet. ' +
'It is never shown to other members, never sold, and you can change it any time.' +
(prefill ? ' We already have this one on file for your payout alerts, so just confirm it.' : '')));
var err = errLine(); card.appendChild(err);
var i = input('pgEmail', 'you@example.com', prefill, 'email');
card.appendChild(i);
var b = button(prefill ? 'Send me the code' : 'Send me a code');
card.appendChild(b);
var codeWrap = el('div', 'display:none;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.1);');
var sentNote = note('', TEAL); codeWrap.appendChild(sentNote);
var ci = input('pgCode', '6-digit code', '');
ci.inputMode = 'numeric'; ci.maxLength = 6;
codeWrap.appendChild(ci);
var cb = button('Confirm and finish');
codeWrap.appendChild(cb);
var again = el('p', 'margin:10px 0 0;font-size:12.5px;color:#7f93a8;text-align:center;cursor:pointer;', 'Wrong address? Change it and send a new code.');
again.addEventListener('click', function () { codeWrap.style.display = 'none'; b.disabled = false; b.textContent = 'Send me a code'; i.focus(); });
codeWrap.appendChild(again);
card.appendChild(codeWrap);
b.addEventListener('click', async function () {
err.style.display = 'none'; b.disabled = true; b.textContent = 'Sending…';
try {
var r = await api('/api/public/profile/email-start', { email: i.value });
sentNote.textContent = 'Code sent to ' + (r.to || i.value) + '. It lasts 15 minutes. Check spam the first time.';
codeWrap.style.display = 'block'; b.textContent = 'Code sent';
setTimeout(function () { ci.focus(); }, 60);
} catch (e) {
err.textContent = e.message; err.style.display = 'block'; b.disabled = false; b.textContent = 'Send me a code';
}
});
var confirm = async function () {
err.style.display = 'none'; cb.disabled = true; cb.textContent = 'Checking…';
try {
var r = await api('/api/public/profile/email-verify', { code: ci.value });
state.profile = r.profile;
done();
} catch (e) {
err.textContent = e.message; err.style.display = 'block'; cb.disabled = false; cb.textContent = 'Confirm and finish';
}
};
cb.addEventListener('click', confirm);
ci.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); confirm(); } });
setTimeout(function () { i.focus(); }, 60);
}
function done() {
var card = head(document.getElementById('pgCard'), 2);
card.innerHTML = '<div style="text-align:center;padding:14px 0 6px">' +
'<div style="font-size:42px;line-height:1">✅</div>' +
'<h2 style="margin:10px 0 6px;font-size:21px;color:#fff">You are all set, @' + esc(state.profile.username) + '</h2>' +
'<p style="margin:0 0 16px;font-size:13.5px;line-height:1.6;color:#9fb3c8">Your leader can reach you now, and every payout to your wallet sends you a note.</p></div>';
var b = button('Open my dashboard');
b.addEventListener('click', close);
card.appendChild(b);
setTimeout(close, 2600);
}
function close() {
if (back && back.parentNode) back.parentNode.removeChild(back);
back = null;
if (resolveDone) { var r = resolveDone; resolveDone = null; r(state && state.profile); }
}
function next() {
if (!state.profile || !state.profile.username) return stepUsername();
if (!state.profile.emailVerified) return stepEmail();
return done();
}
// Resolves once the profile is complete. Safe to call repeatedly: it returns
// immediately when there is nothing to collect, and never shows for a visitor
// who has not proved they own the position (the API 401s them).
async function require_() {
try { state = await api('/api/public/profile'); }
catch (e) { return null; } // not signed in: nothing to gate
if (state.profile && state.profile.complete) return state.profile;
if (back) return null; // already open
shell();
return new Promise(function (res) { resolveDone = res; next(); });
}
async function status() { try { return await api('/api/public/profile'); } catch (e) { return null; } }
window.RMCProfile = { require: require_, status: status };
})();