Add guest auth for Cabo voters
This commit is contained in:
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user