Add guest auth for Cabo voters

This commit is contained in:
TopherMayor
2026-04-30 19:58:22 -07:00
parent e5079cbce4
commit bdd2e5968f
4 changed files with 410 additions and 85 deletions

View File

@@ -399,6 +399,16 @@
font-size: 0.75rem;
color: var(--accent);
}
.voter-badge .role-tag {
font-size: 0.62rem;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgba(52,211,153,0.12);
color: var(--green);
border: 1px solid rgba(52,211,153,0.22);
border-radius: 999px;
padding: 2px 8px;
}
.voter-badge button {
background: none;
border: none;
@@ -437,7 +447,8 @@
}
.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); }
.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; }
.modal input {
.modal input,
.modal select {
width: 100%;
padding: 10px 14px;
background: var(--surface2);
@@ -449,7 +460,26 @@
margin-bottom: 12px;
transition: border-color 0.2s;
}
.modal input:focus { border-color: var(--accent); }
.modal input:focus,
.modal select:focus { border-color: var(--accent); }
.modal select {
appearance: none;
cursor: pointer;
}
.auth-help {
margin-top: -6px;
margin-bottom: 14px;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.4;
}
.auth-status {
min-height: 18px;
margin: -6px 0 12px;
font-size: 0.75rem;
color: var(--red);
text-align: left;
}
.modal button {
width: 100%;
padding: 10px;
@@ -1322,14 +1352,17 @@
<body>
<a class="skip-link" href="#optionsList">Skip to voting options</a>
<!-- Name Modal -->
<div class="modal-overlay" id="nameModal">
<!-- Guest Auth Modal -->
<div class="modal-overlay" id="authModal">
<div class="modal">
<div class="drag-handle"></div>
<h2>🏄 Who's Voting?</h2>
<p>Enter your name so groomsmen know who voted for what. Stored locally — only visible to the group.</p>
<input type="text" id="voterNameInput" placeholder="e.g. Mike, Chris, Dave…" maxlength="30" autocomplete="off" />
<button onclick="submitName()">Join the Vote →</button>
<h2>🔐 Guest Access</h2>
<p>Select your name and enter the last 4 digits of your phone number to unlock voting.</p>
<select id="guestNameSelect" aria-label="Guest name"></select>
<input type="password" id="guestPinInput" placeholder="Last 4 digits" maxlength="4" inputmode="numeric" autocomplete="one-time-code" />
<div class="auth-help" id="authRoleHelp">Jon is marked as the groom and Toph as the best man.</div>
<div class="auth-status" id="authError"></div>
<button onclick="submitAuth()">Join the Vote →</button>
</div>
</div>
@@ -1351,8 +1384,8 @@
<h1>📍 Cabo Bachelor Party — Vote</h1>
<div class="meta">
<div class="voter-badge" id="voterBadge" style="display:none;">
<span>👤 <span id="voterDisplayName"></span></span>
<button onclick="changeName()" title="Change name"></button>
<span>👤 <span id="voterDisplayName"></span> <span class="role-tag" id="voterDisplayRole" style="display:none;"></span></span>
<button onclick="changeName()" title="Switch guest"></button>
</div>
</div>
</header>
@@ -1458,7 +1491,10 @@
<script>
// ── State ──────────────────────────────────────────────────
let state = {
voterName: localStorage.getItem('cabo_voter_name') || '',
voterName: '',
guestRole: localStorage.getItem('cabo_guest_role') || '',
guestAuthToken: localStorage.getItem('cabo_guest_auth_token') || '',
guestRoster: [],
categories: [],
options: [],
budgetScenarios: [],
@@ -1475,6 +1511,7 @@
pollsOpen: true,
totalVoters: 0,
wsConnected: false,
authReady: false,
};
let ws = null;
let activeTab = 'hotel';
@@ -1485,33 +1522,20 @@
// ── Init ───────────────────────────────────────────────────
function init() {
// Check for ?view=results URL param — skip name modal, go to results
// Check for ?view=results URL param and keep the results tab active.
const params = new URLSearchParams(location.search);
const viewResults = params.get('view') === 'results';
if (state.voterName) {
applyVoterName(state.voterName);
// Session persists — skip name modal
document.getElementById('nameModal').classList.add('hidden');
}
// If view=results, skip name modal and go to results tab
if (viewResults) {
activeTab = 'results';
document.getElementById('nameModal').classList.add('hidden');
}
connectWS();
renderTabs();
render();
schedulePriceRefresh();
if (!viewResults) {
document.getElementById('voterNameInput').addEventListener('keydown', e => {
if (e.key === 'Enter') submitName();
});
document.getElementById('voterNameInput').focus();
}
renderGuestAuthOptions();
restoreGuestSession();
// Number keys 1-6 to switch tabs
document.addEventListener('keydown', e => {
@@ -1527,6 +1551,118 @@
});
}
function getGuestRoleLabel(role) {
if (!role) return '';
if (role === 'groom') return 'Groom';
if (role === 'best-man') return 'Best Man';
if (role === 'guest') return '';
return role.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
}
function renderGuestAuthOptions() {
const select = document.getElementById('guestNameSelect');
if (!select) return;
const roster = Array.isArray(state.guestRoster) ? state.guestRoster : [];
const currentValue = select.value || state.voterName || '';
select.innerHTML = [
'<option value="" disabled selected>Select your name</option>',
...roster.map((guest) => {
const roleLabel = getGuestRoleLabel(guest.role);
const label = roleLabel ? `${guest.name} (${roleLabel})` : guest.name;
return `<option value="${escapeHtml(guest.name)}">${escapeHtml(label)}</option>`;
}),
].join('');
if (currentValue && roster.some((guest) => guest.name === currentValue)) {
select.value = currentValue;
}
}
function showAuthModal(message = '') {
document.getElementById('authModal')?.classList.remove('hidden');
const err = document.getElementById('authError');
if (err) err.textContent = message;
renderGuestAuthOptions();
const pinInput = document.getElementById('guestPinInput');
if (pinInput) pinInput.value = '';
setTimeout(() => document.getElementById('guestNameSelect')?.focus(), 0);
}
function hideAuthModal() {
document.getElementById('authModal')?.classList.add('hidden');
const err = document.getElementById('authError');
if (err) err.textContent = '';
}
async function restoreGuestSession() {
if (!state.guestAuthToken) {
state.authReady = true;
showAuthModal();
return;
}
try {
const res = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${state.guestAuthToken}` },
});
if (!res.ok) throw new Error('invalid session');
const payload = await res.json();
applyGuestSession(payload.guest, state.guestAuthToken, true);
} catch {
clearGuestSession();
showAuthModal('Your access code is no longer valid. Please sign in again.');
} finally {
state.authReady = true;
}
}
function clearGuestSession() {
state.voterName = '';
state.guestRole = '';
state.guestAuthToken = '';
localStorage.removeItem('cabo_guest_name');
localStorage.removeItem('cabo_guest_role');
localStorage.removeItem('cabo_guest_auth_token');
document.getElementById('voterBadge').style.display = 'none';
}
function applyGuestSession(guest, token, persist = false) {
state.voterName = guest.name;
state.guestRole = guest.role || 'guest';
state.guestAuthToken = token;
if (persist) {
localStorage.setItem('cabo_guest_name', guest.name);
localStorage.setItem('cabo_guest_role', state.guestRole);
localStorage.setItem('cabo_guest_auth_token', token);
}
updateGuestBadge();
hideAuthModal();
render();
if (pendingVoteOptionId) {
openVoteConfirm(pendingVoteOptionId, pendingVoteRemove);
}
}
function updateGuestBadge() {
if (!state.voterName) {
document.getElementById('voterBadge').style.display = 'none';
return;
}
document.getElementById('voterDisplayName').textContent = state.voterName;
const roleEl = document.getElementById('voterDisplayRole');
const roleLabel = getGuestRoleLabel(state.guestRole);
if (roleEl) {
roleEl.textContent = roleLabel;
roleEl.style.display = roleLabel ? 'inline-flex' : 'none';
}
document.getElementById('voterBadge').style.display = 'inline-flex';
}
// ── WebSocket ──────────────────────────────────────────────
const RECONNECT_BASE = 1000; // 1s initial
const RECONNECT_MAX = 30000; // 30s cap
@@ -1560,15 +1696,18 @@
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'init') {
state.categories = msg.categories;
state.options = msg.options;
state.budgetScenarios = msg.budgetScenarios || [];
state.priceUpdatedAt = msg.priceUpdatedAt || '';
state.priceHistoryRunCount = msg.priceHistoryRunCount || 0;
state.pollsOpen = msg.pollsOpen;
state.totalVoters = msg.totalVoters;
renderTabs();
render();
state.categories = msg.categories;
state.guestRoster = msg.guestRoster || [];
state.options = msg.options;
state.budgetScenarios = msg.budgetScenarios || [];
state.priceUpdatedAt = msg.priceUpdatedAt || '';
state.priceHistoryRunCount = msg.priceHistoryRunCount || 0;
state.pollsOpen = msg.pollsOpen;
state.totalVoters = msg.totalVoters;
renderGuestAuthOptions();
updateGuestBadge();
renderTabs();
render();
} else if (msg.type === 'vote_update') {
msg.results.forEach(r => {
const opt = state.options.find(o => o.id === r.id);
@@ -1591,6 +1730,8 @@
} else if (msg.type === 'polls_status') {
state.pollsOpen = msg.open;
updatePollsBadge();
} else if (msg.type === 'error') {
showToast(msg.message || 'Request failed', 'error');
}
};
@@ -2144,29 +2285,44 @@
`;
}
// ── Name modal ────────────────────────────────────────────
function submitName() {
const name = document.getElementById('voterNameInput').value.trim();
if (!name) return;
state.voterName = name;
localStorage.setItem('cabo_voter_name', name);
applyVoterName(name);
document.getElementById('nameModal').classList.add('hidden');
render();
if (pendingVoteOptionId) {
openVoteConfirm(pendingVoteOptionId, pendingVoteRemove);
// ── Guest auth modal ───────────────────────────────────────
async function submitAuth() {
const name = document.getElementById('guestNameSelect').value.trim();
const pin = document.getElementById('guestPinInput').value.trim();
const err = document.getElementById('authError');
if (!name) {
if (err) err.textContent = 'Choose your name first.';
return;
}
if (!/^\d{4}$/.test(pin)) {
if (err) err.textContent = 'Enter the last 4 digits of your phone number.';
return;
}
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, pin }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
if (err) err.textContent = payload.error || 'That name and code did not match.';
return;
}
applyGuestSession(payload.guest, payload.token, true);
showToast(`Welcome, ${payload.guest.name}!`, 'success');
} catch {
if (err) err.textContent = 'Could not reach the server. Try again.';
}
}
function applyVoterName(name) {
document.getElementById('voterDisplayName').textContent = name;
document.getElementById('voterBadge').style.display = 'inline-flex';
}
function changeName() {
document.getElementById('voterNameInput').value = state.voterName;
document.getElementById('nameModal').classList.remove('hidden');
document.getElementById('voterNameInput').focus();
clearGuestSession();
showAuthModal();
}
function openVoteConfirm(optionId, remove = false) {
@@ -2174,8 +2330,7 @@
if (!state.voterName) {
pendingVoteOptionId = optionId;
pendingVoteRemove = remove;
document.getElementById('nameModal').classList.remove('hidden');
document.getElementById('voterNameInput').focus();
showAuthModal();
return;
}
if (!state.pollsOpen) {
@@ -2220,7 +2375,7 @@
closeVoteConfirm();
return;
}
wsSend({ type: 'vote', optionId, voterName: state.voterName, remove });
wsSend({ type: 'vote', optionId, voterName: state.voterName, authToken: state.guestAuthToken, remove });
showToast(remove ? `Removed vote for ${opt.name}` : `Voted for ${opt.name}!`);
closeVoteConfirm();
}
@@ -2455,12 +2610,11 @@
return;
}
if (!state.voterName) {
document.getElementById('nameModal').classList.remove('hidden');
document.getElementById('voterNameInput').focus();
showAuthModal();
return;
}
wsSend({ type: 'add_option', categoryId: catId, name, desc, url, voterName: state.voterName });
wsSend({ type: 'add_option', categoryId: catId, name, desc, url, voterName: state.voterName, authToken: state.guestAuthToken });
// Clear form
document.getElementById('addName').value = '';