Email-first membership: signup/login, wallet linked at purchase time
Normal people join with email + password (sponsor attribution via cookie at
signup); the wallet only appears when buying or activating payouts, and gets
linked to the account then. Wallet-only sign-in remains for crypto-native
users. Sessions carry {email, address, memberId}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+78
-28
@@ -1,49 +1,99 @@
|
|||||||
// Site-side member records for InstantAdPay.
|
// Site-side member accounts for InstantAdPay.
|
||||||
// The chain is the source of truth for money, credits, and qualification;
|
// The chain is the source of truth for money, credits, and qualification;
|
||||||
// this module holds only what the chain doesn't: free members who haven't
|
// this module holds what the chain doesn't: free members (email + password,
|
||||||
// touched the chain yet, sponsor attribution before first purchase (spec §4),
|
// the way normal people join), sponsor attribution before first purchase
|
||||||
// display handles, and join stats. Wiping this file = the clean reset between
|
// (spec §4), and the wallet link once one is connected at purchase time.
|
||||||
// the Amoy dress rehearsal and mainnet launch.
|
// Wiping this file = the clean reset between rehearsal and mainnet.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
let DATA_DIR = null;
|
let DATA_DIR = null;
|
||||||
const FILE = () => path.join(DATA_DIR, 'accounts.json');
|
const FILE = () => path.join(DATA_DIR, 'accounts.json');
|
||||||
let db = { v: 1, byAddress: {}, joins: 0 };
|
let db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 };
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {}
|
try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {}
|
||||||
if (!db || db.v !== 1) db = { v: 1, byAddress: {}, joins: 0 };
|
if (!db || !db.v) db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 };
|
||||||
|
if (db.v === 1) { db.v = 2; db.byEmail = db.byEmail || {}; } // early rehearsal file
|
||||||
}
|
}
|
||||||
function save() {
|
function save() {
|
||||||
try {
|
try {
|
||||||
const tmp = FILE() + '.tmp';
|
const tmp = FILE() + '.tmp';
|
||||||
fs.writeFileSync(tmp, JSON.stringify(db));
|
fs.writeFileSync(tmp, JSON.stringify(db), { mode: 0o600 });
|
||||||
fs.renameSync(tmp, FILE());
|
fs.renameSync(tmp, FILE());
|
||||||
} catch (e) { console.error('accounts save failed', e.message); }
|
} catch (e) { console.error('accounts save failed', e.message); }
|
||||||
}
|
}
|
||||||
function init(opts) { DATA_DIR = opts.dataDir; load(); }
|
function init(opts) { DATA_DIR = opts.dataDir; load(); }
|
||||||
|
|
||||||
function get(address) { return db.byAddress[(address || '').toLowerCase()] || null; }
|
// ---- password hashing (scrypt, no deps) ----
|
||||||
function upsert(address, fields) {
|
function hashPassword(password) {
|
||||||
const a = (address || '').toLowerCase();
|
const salt = crypto.randomBytes(16);
|
||||||
if (!/^0x[0-9a-f]{40}$/.test(a)) return null;
|
const hash = crypto.scryptSync(String(password), salt, 32);
|
||||||
const cur = db.byAddress[a] || { created: Date.now() };
|
return salt.toString('hex') + ':' + hash.toString('hex');
|
||||||
db.byAddress[a] = Object.assign(cur, fields || {});
|
|
||||||
save();
|
|
||||||
return db.byAddress[a];
|
|
||||||
}
|
}
|
||||||
// Sponsor attribution: first touch wins, written on-chain at the member's
|
function checkPassword(password, stored) {
|
||||||
// first purchase/activation and permanent from then on.
|
try {
|
||||||
function attributeSponsor(address, sponsorId) {
|
const [saltHex, hashHex] = String(stored).split(':');
|
||||||
const a = (address || '').toLowerCase();
|
const hash = crypto.scryptSync(String(password), Buffer.from(saltHex, 'hex'), 32);
|
||||||
const cur = get(a);
|
return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'));
|
||||||
if (cur && cur.sponsorId) return cur.sponsorId; // first touch already set
|
} catch (e) { return false; }
|
||||||
const id = Number(sponsorId) || 0;
|
|
||||||
upsert(a, { sponsorId: id });
|
|
||||||
db.joins += 1; save();
|
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
function count() { return Object.keys(db.byAddress).length; }
|
|
||||||
|
|
||||||
module.exports = { init, get, upsert, attributeSponsor, count };
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||||
|
const normEmail = e => String(e || '').trim().toLowerCase();
|
||||||
|
const normAddr = a => String(a || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
// ---- email accounts (the normal join path) ----
|
||||||
|
function signup(email, password, sponsorId) {
|
||||||
|
const e = normEmail(email);
|
||||||
|
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
|
||||||
|
if (String(password || '').length < 8) return { error: 'Password needs at least 8 characters.' };
|
||||||
|
if (db.byEmail[e]) return { error: 'That email already has an account. Log in instead.' };
|
||||||
|
db.byEmail[e] = {
|
||||||
|
email: e,
|
||||||
|
pass: hashPassword(password),
|
||||||
|
sponsorId: Number(sponsorId) || 0, // first touch, written on-chain at first purchase
|
||||||
|
address: null,
|
||||||
|
created: Date.now()
|
||||||
|
};
|
||||||
|
db.joins += 1;
|
||||||
|
save();
|
||||||
|
return { ok: true, account: publicView(db.byEmail[e]) };
|
||||||
|
}
|
||||||
|
function login(email, password) {
|
||||||
|
const e = normEmail(email);
|
||||||
|
const acct = db.byEmail[e];
|
||||||
|
if (!acct || !checkPassword(password, acct.pass)) return { error: 'Wrong email or password.' };
|
||||||
|
acct.lastSeen = Date.now(); save();
|
||||||
|
return { ok: true, account: publicView(acct) };
|
||||||
|
}
|
||||||
|
function byEmail(email) { const a = db.byEmail[normEmail(email)]; return a ? publicView(a) : null; }
|
||||||
|
function byAddress(address) {
|
||||||
|
const e = db.byAddress[normAddr(address)];
|
||||||
|
return e ? publicView(db.byEmail[e]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- wallet link (happens at purchase / payout activation time) ----
|
||||||
|
// First link wins and is permanent for the account; one wallet, one account.
|
||||||
|
function linkWallet(email, address) {
|
||||||
|
const e = normEmail(email);
|
||||||
|
const a = normAddr(address);
|
||||||
|
const acct = db.byEmail[e];
|
||||||
|
if (!acct) return { error: 'No such account.' };
|
||||||
|
if (!/^0x[0-9a-f]{40}$/.test(a)) return { error: 'Bad wallet address.' };
|
||||||
|
if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet '
|
||||||
|
+ acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Earnings pay to that wallet. Connect it instead.' };
|
||||||
|
if (db.byAddress[a] && db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' };
|
||||||
|
acct.address = a;
|
||||||
|
db.byAddress[a] = e;
|
||||||
|
save();
|
||||||
|
return { ok: true, account: publicView(acct) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicView(a) {
|
||||||
|
return { email: a.email, sponsorId: a.sponsorId || 0, address: a.address || null, created: a.created };
|
||||||
|
}
|
||||||
|
function count() { return Object.keys(db.byEmail).length; }
|
||||||
|
|
||||||
|
module.exports = { init, signup, login, byEmail, byAddress, linkWallet, count };
|
||||||
|
|||||||
@@ -84,12 +84,22 @@ async function verifyChallenge(address, signature) {
|
|||||||
if (rec !== a) return { error: 'Your wallet signed with a different account than the page is using ('
|
if (rec !== a) return { error: 'Your wallet signed with a different account than the page is using ('
|
||||||
+ rec.slice(0, 6) + '…' + rec.slice(-4) + '). Switch accounts and tap sign-in again.' };
|
+ rec.slice(0, 6) + '…' + rec.slice(-4) + '). Switch accounts and tap sign-in again.' };
|
||||||
challenges.delete(a);
|
challenges.delete(a);
|
||||||
let memberId = 0;
|
return { ok: true, address: a };
|
||||||
try { memberId = await chain.memberIdByAccount(a); } catch (e) { /* chain read down: session still valid */ }
|
}
|
||||||
|
// Sessions carry {email, address, memberId} — email accounts are the normal
|
||||||
|
// join path; the wallet fields fill in when one is linked at purchase time.
|
||||||
|
function mintSession(fields) {
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
sessions.set(token, { address: a, memberId, expires: Date.now() + SESSION_TTL });
|
sessions.set(token, Object.assign({ email: null, address: null, memberId: 0 }, fields,
|
||||||
|
{ expires: Date.now() + SESSION_TTL }));
|
||||||
|
saveSessions();
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
function updateSession(token, fields) {
|
||||||
|
const s = sessions.get(token);
|
||||||
|
if (!s) return;
|
||||||
|
sessions.set(token, Object.assign({}, s, fields));
|
||||||
saveSessions();
|
saveSessions();
|
||||||
return { token, address: a, memberId };
|
|
||||||
}
|
}
|
||||||
function sessionCookie(token) {
|
function sessionCookie(token) {
|
||||||
return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`;
|
return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`;
|
||||||
@@ -105,16 +115,16 @@ function fromRequest(req) {
|
|||||||
}
|
}
|
||||||
async function refreshMemberId(sess) {
|
async function refreshMemberId(sess) {
|
||||||
// called after an on-chain action so the session learns its new member id
|
// called after an on-chain action so the session learns its new member id
|
||||||
|
if (!sess.address) return sess.memberId || 0;
|
||||||
try {
|
try {
|
||||||
const id = await chain.memberIdByAccount(sess.address);
|
const id = await chain.memberIdByAccount(sess.address);
|
||||||
if (id && id !== sess.memberId) { sess.memberId = id; sessions.set(sess.token, {
|
if (id && id !== sess.memberId) updateSession(sess.token, { memberId: id });
|
||||||
address: sess.address, memberId: id, expires: sess.expires }); saveSessions(); }
|
return id || sess.memberId || 0;
|
||||||
return id;
|
} catch (e) { return sess.memberId || 0; }
|
||||||
} catch (e) { return sess.memberId; }
|
|
||||||
}
|
}
|
||||||
function logout(req) {
|
function logout(req) {
|
||||||
const s = fromRequest(req);
|
const s = fromRequest(req);
|
||||||
if (s) { sessions.delete(s.token); saveSessions(); }
|
if (s) { sessions.delete(s.token); saveSessions(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, makeChallenge, verifyChallenge, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout };
|
module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout };
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
async function buyPack(btn) {
|
async function buyPack(btn) {
|
||||||
try {
|
try {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
// email members get their wallet linked to the account at buy time
|
||||||
|
const me = await (await fetch('/api/me')).json();
|
||||||
|
if (me.signedIn && me.email && !me.address) {
|
||||||
|
IAP.status('First, a free signature links your wallet to your account…');
|
||||||
|
await IAPWallet.signIn();
|
||||||
|
}
|
||||||
IAP.status('Confirm the purchase in your wallet…');
|
IAP.status('Confirm the purchase in your wallet…');
|
||||||
const r = await IAPWallet.buy(Number(btn.dataset.id), sp.sponsorId || 0, btn.dataset.cost);
|
const r = await IAPWallet.buy(Number(btn.dataset.id), sp.sponsorId || 0, btn.dataset.cost);
|
||||||
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
||||||
|
|||||||
+74
-38
@@ -1,36 +1,51 @@
|
|||||||
// My account: SIWE sign-in, member state, free activation, invite link.
|
// My account: email-first join/login, wallet link at purchase time,
|
||||||
|
// free payout activation, invite link, on-chain activity.
|
||||||
(async function () {
|
(async function () {
|
||||||
await IAP.renderNav('my');
|
await IAP.renderNav('my');
|
||||||
const $ = IAP.$;
|
const $ = IAP.$;
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const r = await (await fetch(path, { method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json();
|
||||||
|
if (r.error) throw new Error(r.error);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
const me = await IAP.refreshNavWallet();
|
const me = await IAP.refreshNavWallet();
|
||||||
if (!me || !me.signedIn) { $('signinCard').hidden = false; $('memberArea').hidden = true; return; }
|
const signedIn = me && me.signedIn;
|
||||||
$('signinCard').hidden = true;
|
$('authArea').hidden = !!signedIn;
|
||||||
$('memberArea').hidden = false;
|
$('memberArea').hidden = !signedIn;
|
||||||
|
if (!signedIn) return;
|
||||||
|
|
||||||
|
const who = [];
|
||||||
|
if (me.email) who.push(me.email);
|
||||||
|
if (me.address) who.push('wallet <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span>');
|
||||||
|
else who.push('no wallet linked yet');
|
||||||
|
if (me.memberId) who.push('on-chain <b>member #' + me.memberId + '</b>'
|
||||||
|
+ (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : ''));
|
||||||
|
else if (me.sponsorId) who.push('invited by member #' + me.sponsorId);
|
||||||
|
$('posLine').innerHTML = who.join('<br>');
|
||||||
|
|
||||||
|
$('creditLine').textContent = (me.credits || 0).toLocaleString();
|
||||||
|
$('linkCard').hidden = !!me.address;
|
||||||
|
$('activateCard').hidden = !(me.address && !me.memberId);
|
||||||
|
$('activityArea').hidden = !me.memberId;
|
||||||
|
|
||||||
if (me.memberId) {
|
if (me.memberId) {
|
||||||
$('posLine').innerHTML = 'On-chain <b>member #' + me.memberId + '</b><br>wallet <span class="mono">'
|
|
||||||
+ me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span>'
|
|
||||||
+ (me.onchainSponsorId ? '<br>sponsored by member #' + me.onchainSponsorId : '<br>no sponsor (house line)');
|
|
||||||
$('creditLine').textContent = (me.credits || 0).toLocaleString();
|
|
||||||
const bc = me.buyerCount || 0;
|
const bc = me.buyerCount || 0;
|
||||||
$('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>'
|
$('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>'
|
||||||
+ (bc >= 5 ? '<span class="badge">Level 3 unlocked: full three-level earnings</span>'
|
+ (bc >= 5 ? '<span class="badge">Level 3 unlocked: full three-level earnings</span>'
|
||||||
: bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3'
|
: bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3'
|
||||||
: (2 - bc) + ' more ≥$20 buyer(s) unlock level 2');
|
: (2 - bc) + ' more buyer(s) of $20+ unlock level 2');
|
||||||
$('activateCard').hidden = true;
|
|
||||||
$('inviteLine').textContent = location.origin + '/join/' + me.memberId;
|
$('inviteLine').textContent = location.origin + '/join/' + me.memberId;
|
||||||
$('copyInvite').hidden = false;
|
$('copyInvite').hidden = false;
|
||||||
loadActivity();
|
loadActivity();
|
||||||
} else {
|
} else {
|
||||||
$('posLine').innerHTML = 'Signed in as <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6)
|
$('qualLine').textContent = 'Level 1 pays the moment you are on-chain. Referrals who buy packages of $20 or more unlock levels 2 and 3.';
|
||||||
+ '</span><br>free member, not on-chain yet'
|
$('inviteLine').textContent = me.address
|
||||||
+ (me.sponsorId ? '<br>invited by member #' + me.sponsorId : '');
|
? 'Activate payouts above and your invite link appears here.'
|
||||||
$('creditLine').textContent = '0';
|
: 'Link a wallet and switch on payouts to get your invite link.';
|
||||||
$('qualLine').textContent = 'Activate your payout wallet (or buy any package) to start; referrals who buy ≥$20 packages qualify you.';
|
|
||||||
$('activateCard').hidden = false;
|
|
||||||
$('inviteLine').textContent = 'Your link appears after your free on-chain activation.';
|
|
||||||
$('copyInvite').hidden = true;
|
$('copyInvite').hidden = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,43 +57,64 @@
|
|||||||
const fill = (id, evs, empty) => {
|
const fill = (id, evs, empty) => {
|
||||||
const el = $(id);
|
const el = $(id);
|
||||||
el.innerHTML = '';
|
el.innerHTML = '';
|
||||||
if (!evs.length) { el.innerHTML = '<div class="row muted">' + empty + '</div>'; return; }
|
if (!evs || !evs.length) { el.innerHTML = '<div class="row muted">' + empty + '</div>'; return; }
|
||||||
for (const ev of evs) el.appendChild(IAP.feedRow(ev, c));
|
for (const ev of evs) el.appendChild(IAP.feedRow(ev, c));
|
||||||
};
|
};
|
||||||
fill('earnFeed', a.earnings, 'No payouts yet. They appear here the moment one lands.');
|
fill('earnFeed', a.earnings, 'No payouts yet. They appear here the moment one lands.');
|
||||||
fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.');
|
fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.');
|
||||||
fill('buyFeed', a.purchases, 'No purchases from this wallet yet.');
|
fill('buyFeed', a.purchases, 'No purchases from your wallet yet.');
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
$('signinBtn').addEventListener('click', async () => {
|
const busy = (btn, fn) => async () => {
|
||||||
|
try { btn.disabled = true; await fn(); }
|
||||||
|
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
|
||||||
|
finally { btn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
$('signupBtn').addEventListener('click', busy($('signupBtn'), async () => {
|
||||||
|
await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value });
|
||||||
|
IAP.status('Welcome aboard. You are in.', 'ok');
|
||||||
|
await render();
|
||||||
|
}));
|
||||||
|
$('loginBtn').addEventListener('click', busy($('loginBtn'), async () => {
|
||||||
|
await api('/api/login', { email: $('liEmail').value, password: $('liPass').value });
|
||||||
|
IAP.status('Logged in.', 'ok');
|
||||||
|
await render();
|
||||||
|
}));
|
||||||
|
$('walletSigninLink').addEventListener('click', async e => {
|
||||||
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
$('signinBtn').disabled = true;
|
|
||||||
IAP.status('Check your wallet for the free sign-in signature…');
|
IAP.status('Check your wallet for the free sign-in signature…');
|
||||||
await IAPWallet.signIn();
|
await IAPWallet.signIn();
|
||||||
IAP.status('Signed in.', 'ok');
|
IAP.status('Signed in with your wallet.', 'ok');
|
||||||
await render();
|
await render();
|
||||||
} catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
|
} catch (err) { IAP.status((err && err.message) || String(err), 'bad'); }
|
||||||
finally { $('signinBtn').disabled = false; }
|
|
||||||
});
|
});
|
||||||
|
$('linkBtn').addEventListener('click', busy($('linkBtn'), async () => {
|
||||||
$('activateBtn').addEventListener('click', async () => {
|
IAP.status('Check your wallet for the free link signature…');
|
||||||
try {
|
await IAPWallet.signIn(); // server binds the wallet to the signed-in email account
|
||||||
$('activateBtn').disabled = true;
|
IAP.status('Wallet linked. Earnings pay there from now on.', 'ok');
|
||||||
const me = await (await fetch('/api/me')).json();
|
await render();
|
||||||
IAP.status('Confirm the free activation in your wallet…');
|
}));
|
||||||
const r = await IAPWallet.activate(me.sponsorId || 0);
|
$('activateBtn').addEventListener('click', busy($('activateBtn'), async () => {
|
||||||
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
const me = await (await fetch('/api/me')).json();
|
||||||
IAP.status('Payout wallet activated. Your invite link is live.', 'ok');
|
IAP.status('Confirm the free activation in your wallet…');
|
||||||
await render();
|
const r = await IAPWallet.activate(me.sponsorId || 0);
|
||||||
} catch (e) { IAP.status('Activation failed: ' + ((e && e.message) || e), 'bad'); }
|
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
||||||
finally { $('activateBtn').disabled = false; }
|
IAP.status('Payouts are on. Your invite link is live.', 'ok');
|
||||||
});
|
await render();
|
||||||
|
}));
|
||||||
$('copyInvite').addEventListener('click', async () => {
|
$('copyInvite').addEventListener('click', async () => {
|
||||||
try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); }
|
try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); }
|
||||||
catch (e) { IAP.status('Copy failed. Select and copy the link text.', 'bad'); }
|
catch (e) { IAP.status('Copy failed. Select and copy the link text.', 'bad'); }
|
||||||
});
|
});
|
||||||
|
$('logoutLink').addEventListener('click', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
IAP.status('Logged out.', 'ok');
|
||||||
|
await render();
|
||||||
|
});
|
||||||
|
|
||||||
render();
|
render();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+55
-30
@@ -9,66 +9,91 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<section class="hero" style="padding-bottom:16px">
|
<section class="hero" style="padding-bottom:16px">
|
||||||
<h1>My <em>account</em></h1>
|
<h1>My <em>account</em></h1>
|
||||||
<p class="lead" id="introLead">Sign in with one free wallet signature. No email. No password.
|
<p class="lead" id="introLead">Join free with your email. Your wallet only comes out when you buy
|
||||||
No account to create. The signature is free and cannot move funds.</p>
|
a package or switch on payouts, and it stays yours the whole time.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="card" id="signinCard">
|
<div id="authArea">
|
||||||
<h3>Sign in with your wallet</h3>
|
<div class="grid c2">
|
||||||
<p class="muted small">New here? The same button creates your free membership. Your earnings always go
|
<div class="card">
|
||||||
straight to this wallet. We never hold them.</p>
|
<h3>Create your free account</h3>
|
||||||
<button class="btn" id="signinBtn">Connect & sign in</button>
|
<p class="muted small">Takes ten seconds. No wallet needed to join.</p>
|
||||||
|
<p><input id="suEmail" type="email" placeholder="Email" autocomplete="email" style="width:100%"></p>
|
||||||
|
<p><input id="suPass" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" style="width:100%"></p>
|
||||||
|
<button class="btn" id="signupBtn">Join free</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Log in</h3>
|
||||||
|
<p class="muted small">Welcome back.</p>
|
||||||
|
<p><input id="liEmail" type="email" placeholder="Email" autocomplete="email" style="width:100%"></p>
|
||||||
|
<p><input id="liPass" type="password" placeholder="Password" autocomplete="current-password" style="width:100%"></p>
|
||||||
|
<button class="btn sec" id="loginBtn">Log in</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="small muted">Crypto-native? You can also <a href="#" id="walletSigninLink">sign in with just your wallet</a>.
|
||||||
|
One free signature, no email needed.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="memberArea" hidden>
|
<div id="memberArea" hidden>
|
||||||
<div class="grid c3">
|
<div class="grid c3">
|
||||||
<div class="card"><h3>Your position</h3>
|
<div class="card"><h3>Your account</h3>
|
||||||
<p id="posLine" class="muted small">…</p></div>
|
<p id="posLine" class="muted small">…</p></div>
|
||||||
<div class="card"><h3>Ad credits</h3>
|
<div class="card"><h3>Ad credits</h3>
|
||||||
<p class="mono" style="font-size:26px;margin:0" id="creditLine">—</p>
|
<p class="mono" style="font-size:26px;margin:0" id="creditLine">0</p>
|
||||||
<p class="muted small">1 credit = 1¢ of delivery across the network. Recorded on-chain; only your campaigns can spend them.</p></div>
|
<p class="muted small">1 credit = 1 cent of ad delivery across the network. Recorded on-chain. Only your campaigns can spend them.</p></div>
|
||||||
<div class="card"><h3>Qualification</h3>
|
<div class="card"><h3>Earning levels</h3>
|
||||||
<p id="qualLine" class="muted small">…</p></div>
|
<p id="qualLine" class="muted small">…</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="linkCard" hidden>
|
||||||
|
<h3>Link your wallet</h3>
|
||||||
|
<p class="muted small">Your earnings pay straight to your own wallet, so we need to know which one is yours.
|
||||||
|
One free signature links it. It cannot move funds or approve anything. Buying any package links it automatically too.</p>
|
||||||
|
<button class="btn" id="linkBtn">Connect & link wallet</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" id="activateCard" hidden>
|
<div class="card" id="activateCard" hidden>
|
||||||
<h3>Activate your payout wallet, free</h3>
|
<h3>Switch on payouts, free</h3>
|
||||||
<p class="muted small">One free transaction registers this wallet on-chain so commissions can reach it.
|
<p class="muted small">One free transaction registers your linked wallet on-chain so commissions can reach it,
|
||||||
Buying any package does this automatically, so you can also just start with a package below.</p>
|
and it unlocks your invite link. Buying any package does this automatically.</p>
|
||||||
<button class="btn sec" id="activateBtn">Activate payout wallet</button>
|
<button class="btn sec" id="activateBtn">Activate payouts</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Your invite link</h3>
|
<h3>Your invite link</h3>
|
||||||
<p class="muted small">Share it anywhere. Everyone who joins through it becomes part of your line.
|
<p class="muted small">Share it anywhere. Everyone who joins through it becomes part of your line.
|
||||||
You earn 50 percent of every ad package they ever buy, plus levels 2 and 3 of their teams as you qualify.</p>
|
You earn 50 percent of every ad package they ever buy, plus levels 2 and 3 of their teams as you qualify.</p>
|
||||||
<p class="mono" id="inviteLine">Sign in to get your link.</p>
|
<p class="mono" id="inviteLine">…</p>
|
||||||
<button class="btn small sec" id="copyInvite" hidden>Copy link</button>
|
<button class="btn small sec" id="copyInvite" hidden>Copy link</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Buy ad packages</h3>
|
<h3>Buy ad packages</h3>
|
||||||
<p class="muted small">The full ladder with live pricing is on the <a href="/">home page</a>.
|
<p class="muted small">The full ladder with live pricing is on the <a href="/">home page</a>.
|
||||||
Purchases from this wallet automatically credit this account.</p>
|
Purchases from your linked wallet automatically credit this account.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Your activity, straight from the chain</h2>
|
<div id="activityArea" hidden>
|
||||||
<div class="grid c2">
|
<h2>Your activity, straight from the chain</h2>
|
||||||
<div class="card">
|
<div class="grid c2">
|
||||||
<h3>Earnings</h3>
|
<div class="card">
|
||||||
<p class="muted small">Every payout the contract has sent to your wallet. Each line links to the transaction.</p>
|
<h3>Earnings</h3>
|
||||||
<div class="feed" id="earnFeed"><div class="row muted">Nothing yet.</div></div>
|
<p class="muted small">Every payout the contract has sent to your wallet. Each line links to the transaction.</p>
|
||||||
|
<div class="feed" id="earnFeed"><div class="row muted">Nothing yet.</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Your referrals</h3>
|
||||||
|
<p class="muted small">Activations and qualifying buys in your line.</p>
|
||||||
|
<div class="feed" id="refFeed"><div class="row muted">Nothing yet.</div></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Your referrals</h3>
|
<h3>Your purchases</h3>
|
||||||
<p class="muted small">Activations and qualifying buys in your line.</p>
|
<div class="feed" id="buyFeed"><div class="row muted">Nothing yet.</div></div>
|
||||||
<div class="feed" id="refFeed"><div class="row muted">Nothing yet.</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<h3>Your purchases</h3>
|
<p class="small"><a href="#" id="logoutLink">Log out</a></p>
|
||||||
<div class="feed" id="buyFeed"><div class="row muted">Nothing yet.</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer><div>InstantAdPay · <a href="/ledger">live ledger</a></div></footer>
|
<footer><div>InstantAdPay · <a href="/ledger">live ledger</a></div></footer>
|
||||||
|
|||||||
@@ -134,7 +134,28 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return json(res, 200, { onchainMembers: members, siteAccounts: accounts.count() });
|
return json(res, 200, { onchainMembers: members, siteAccounts: accounts.count() });
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- auth
|
// -- accounts: email + password is the normal join path (wallet comes
|
||||||
|
// out only at purchase / payout-activation time and gets linked then)
|
||||||
|
if (p === '/api/signup' && req.method === 'POST') {
|
||||||
|
const b = await readBody(req);
|
||||||
|
const sid = Number(parseCookies(req)['iap.sponsor']) || 0; // first-touch attribution
|
||||||
|
const r = accounts.signup(b.email, b.password, sid);
|
||||||
|
if (r.error) return json(res, 400, r);
|
||||||
|
const token = auth.mintSession({ email: r.account.email });
|
||||||
|
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
|
||||||
|
}
|
||||||
|
if (p === '/api/login' && req.method === 'POST') {
|
||||||
|
const b = await readBody(req);
|
||||||
|
const r = accounts.login(b.email, b.password);
|
||||||
|
if (r.error) return json(res, 400, r);
|
||||||
|
let memberId = 0;
|
||||||
|
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (e) {} }
|
||||||
|
const token = auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
|
||||||
|
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- wallet auth: link-to-account when an email session exists, or
|
||||||
|
// wallet-first sign-in for crypto-native users
|
||||||
if (p === '/api/auth/challenge' && req.method === 'POST') {
|
if (p === '/api/auth/challenge' && req.method === 'POST') {
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
const r = auth.makeChallenge(b.address);
|
const r = auth.makeChallenge(b.address);
|
||||||
@@ -144,12 +165,19 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
const r = await auth.verifyChallenge(b.address, b.signature);
|
const r = await auth.verifyChallenge(b.address, b.signature);
|
||||||
if (r.error) return json(res, 400, r);
|
if (r.error) return json(res, 400, r);
|
||||||
// bind the visitor's sponsor cookie to this wallet, first touch wins
|
let memberId = 0;
|
||||||
const sid = Number(parseCookies(req)['iap.sponsor']) || 0;
|
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
|
||||||
const attributed = accounts.attributeSponsor(r.address, sid);
|
const s = auth.fromRequest(req);
|
||||||
accounts.upsert(r.address, { lastSeen: Date.now() });
|
if (s && s.email) {
|
||||||
return json(res, 200, { ok: true, address: r.address, memberId: r.memberId, sponsorId: attributed },
|
const lr = accounts.linkWallet(s.email, r.address);
|
||||||
{ 'Set-Cookie': auth.sessionCookie(r.token) });
|
if (lr.error) return json(res, 400, lr);
|
||||||
|
auth.updateSession(s.token, { address: r.address, memberId });
|
||||||
|
return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
|
||||||
|
}
|
||||||
|
const acct = accounts.byAddress(r.address);
|
||||||
|
const token = auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
|
||||||
|
return json(res, 200, { ok: true, address: r.address, memberId },
|
||||||
|
{ 'Set-Cookie': auth.sessionCookie(token) });
|
||||||
}
|
}
|
||||||
if (p === '/api/auth/logout' && req.method === 'POST') {
|
if (p === '/api/auth/logout' && req.method === 'POST') {
|
||||||
auth.logout(req);
|
auth.logout(req);
|
||||||
@@ -159,8 +187,10 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const s = auth.fromRequest(req);
|
const s = auth.fromRequest(req);
|
||||||
if (!s) return json(res, 200, { signedIn: false });
|
if (!s) return json(res, 200, { signedIn: false });
|
||||||
const memberId = await auth.refreshMemberId(s);
|
const memberId = await auth.refreshMemberId(s);
|
||||||
const acct = accounts.get(s.address) || {};
|
const acct = (s.email && accounts.byEmail(s.email)) || (s.address && accounts.byAddress(s.address)) || null;
|
||||||
const out = { signedIn: true, address: s.address, memberId, sponsorId: acct.sponsorId || 0 };
|
const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
|
||||||
|
address: s.address || (acct && acct.address) || null, memberId,
|
||||||
|
sponsorId: (acct && acct.sponsorId) || Number(parseCookies(req)['iap.sponsor']) || 0 };
|
||||||
if (memberId) {
|
if (memberId) {
|
||||||
try {
|
try {
|
||||||
const mm = await chain.member(memberId);
|
const mm = await chain.member(memberId);
|
||||||
|
|||||||
Reference in New Issue
Block a user