Add guest auth for Cabo voters
This commit is contained in:
@@ -20,6 +20,7 @@ node server.js
|
||||
- **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources
|
||||
- **Package vs standalone labels** — bundled flight+hotel quotes stay distinct from room-only, flight-only, tee-time, table, charter, and excursion prices
|
||||
- **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option
|
||||
- **Guest authentication** — bachelor-party voters sign in with their name and the last 4 digits of their phone number
|
||||
- **Add suggestions** — anyone can propose new venues
|
||||
- **Admin approval** — pending options require approval before going live
|
||||
- **Responsive** — works on desktop and mobile
|
||||
@@ -30,6 +31,7 @@ Votes are stored in `data/votes.json` (created on first run). Edit directly or u
|
||||
System seed data auto-refreshes researched options while preserving existing votes and user-added options.
|
||||
Price-watch automation runs append time-series snapshots in `price-watch/history.jsonl`, which the app turns into per-option trend lines and decision detail cards. Automation output should cover hotels, flights, golf, nightlife, and excursions, with `bookingType` and `priceBasis` separating package quotes from standalone booking prices.
|
||||
When a run includes calculated `budgetScenarios` or `derivedItineraries`, the app uses those fresh automation calculations instead of the static seed budget scenarios.
|
||||
Guest access is rostered in `seed-data.js` and `data/votes.json`; Jon is marked as groom and Toph as best man.
|
||||
|
||||
For hosted deployments, set `DATA_DIR` or `DATA_FILE` so mutable vote data lives outside the Git checkout.
|
||||
When price-watch automation updates tracked data files in the repository, commit/push those changes and refresh the Ubuntu deployment so the hosted app picks up the latest option details, price history, itinerary calculations, and budget scenarios.
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
21
seed-data.js
21
seed-data.js
@@ -1,4 +1,4 @@
|
||||
const SEED_VERSION = 5;
|
||||
const SEED_VERSION = 6;
|
||||
const PRICE_UPDATED_AT = '2026-04-29';
|
||||
|
||||
const CATEGORY_META = {
|
||||
@@ -12,6 +12,23 @@ const CATEGORY_META = {
|
||||
results: { emoji: '🏆', color: '#facc15' },
|
||||
};
|
||||
|
||||
const GUEST_ROSTER = [
|
||||
{ name: 'Jon', last4: '7506', role: 'groom' },
|
||||
{ name: 'Toph', last4: '8116', role: 'best-man' },
|
||||
{ name: 'Hans', last4: '6681', role: 'guest' },
|
||||
{ name: 'Janno', last4: '2809', role: 'guest' },
|
||||
{ name: 'JT', last4: '3286', role: 'guest' },
|
||||
{ name: 'Cordero', last4: '0379', role: 'guest' },
|
||||
{ name: 'Lester', last4: '8014', role: 'guest' },
|
||||
{ name: 'Nick', last4: '6044', role: 'guest' },
|
||||
{ name: 'David', last4: '5993', role: 'guest' },
|
||||
{ name: 'Poalo', last4: '9922', role: 'guest' },
|
||||
{ name: 'Justin', last4: '2329', role: 'guest' },
|
||||
{ name: 'Ben Stewart', last4: '1957', role: 'guest' },
|
||||
{ name: 'Joseph', last4: '4976', role: 'guest' },
|
||||
{ name: 'Francis', last4: '4934', role: 'guest' },
|
||||
];
|
||||
|
||||
const BUDGET_SCENARIOS = [
|
||||
{
|
||||
id: 'budget-8',
|
||||
@@ -190,6 +207,7 @@ function buildSeedData() {
|
||||
{ id: 'budget', name: 'Budget', emoji: '💸' },
|
||||
{ id: 'results', name: 'Results', emoji: '🏆' },
|
||||
],
|
||||
guestRoster: GUEST_ROSTER,
|
||||
budgetScenarios: BUDGET_SCENARIOS,
|
||||
options: [
|
||||
createOption({
|
||||
@@ -738,6 +756,7 @@ function mergeSeedData(existing = {}) {
|
||||
priceUpdatedAt: seed.priceUpdatedAt,
|
||||
categories: [...seed.categories, ...preservedCustomCategories],
|
||||
budgetScenarios: seed.budgetScenarios,
|
||||
guestRoster: seed.guestRoster,
|
||||
options: [...mergedSeedOptions, ...preservedCustomOptions],
|
||||
voters: Array.isArray(existing.voters) ? existing.voters : [],
|
||||
pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true,
|
||||
|
||||
196
server.js
196
server.js
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const cors = require('cors');
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
@@ -65,6 +66,14 @@ function normalizeKey(value) {
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function normalizeGuestPin(value) {
|
||||
return String(value || '').replace(/\D/g, '').slice(-4);
|
||||
}
|
||||
|
||||
function normalizeGuestName(value) {
|
||||
return normalizeKey(value).replace(/-/g, ' ');
|
||||
}
|
||||
|
||||
function normalizeSourceLabel(value) {
|
||||
return String(value || 'Unknown source').trim() || 'Unknown source';
|
||||
}
|
||||
@@ -112,6 +121,91 @@ function inferBookingType(point, defaults = {}) {
|
||||
return 'standalone';
|
||||
}
|
||||
|
||||
const GUEST_AUTH_SECRET = process.env.GUEST_AUTH_SECRET || 'cabo-bachelor-party-guest-auth';
|
||||
|
||||
function getGuestRoster() {
|
||||
return Array.isArray(data.guestRoster) ? data.guestRoster : [];
|
||||
}
|
||||
|
||||
function findGuestByNameAndPin(name, pin) {
|
||||
const normalizedName = normalizeGuestName(name);
|
||||
const normalizedPin = normalizeGuestPin(pin);
|
||||
|
||||
return getGuestRoster().find((guest) => (
|
||||
normalizeGuestName(guest.name) === normalizedName
|
||||
&& normalizeGuestPin(guest.last4) === normalizedPin
|
||||
)) || null;
|
||||
}
|
||||
|
||||
function signGuestToken(guest) {
|
||||
const payload = {
|
||||
name: guest.name,
|
||||
last4: normalizeGuestPin(guest.last4),
|
||||
role: guest.role || 'guest',
|
||||
issuedAt: Date.now(),
|
||||
};
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signature = crypto
|
||||
.createHmac('sha256', GUEST_AUTH_SECRET)
|
||||
.update(encodedPayload)
|
||||
.digest('base64url');
|
||||
|
||||
return `${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
function verifyGuestToken(token) {
|
||||
if (typeof token !== 'string' || !token.includes('.')) return null;
|
||||
|
||||
const [encodedPayload, signature] = token.split('.');
|
||||
if (!encodedPayload || !signature) return null;
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', GUEST_AUTH_SECRET)
|
||||
.update(encodedPayload)
|
||||
.digest('base64url');
|
||||
|
||||
const sigA = Buffer.from(signature);
|
||||
const sigB = Buffer.from(expectedSignature);
|
||||
if (sigA.length !== sigB.length || !crypto.timingSafeEqual(sigA, sigB)) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
|
||||
const guest = findGuestByNameAndPin(payload.name, payload.last4);
|
||||
if (!guest) return null;
|
||||
return {
|
||||
name: guest.name,
|
||||
last4: normalizeGuestPin(guest.last4),
|
||||
role: guest.role || 'guest',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readGuestAuthToken(req, body = {}) {
|
||||
const bodyToken = body.authToken || body.guestToken || body.token;
|
||||
if (typeof bodyToken === 'string' && bodyToken.trim()) return bodyToken.trim();
|
||||
|
||||
const headerToken = req.headers['x-guest-auth'];
|
||||
if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim();
|
||||
|
||||
const authorization = req.headers.authorization || '';
|
||||
if (authorization.toLowerCase().startsWith('bearer ')) {
|
||||
return authorization.slice(7).trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireGuestAuth(req, res, body = {}) {
|
||||
const guest = verifyGuestToken(readGuestAuthToken(req, body));
|
||||
if (!guest) {
|
||||
res.status(401).json({ error: 'Guest authentication required' });
|
||||
return null;
|
||||
}
|
||||
return guest;
|
||||
}
|
||||
|
||||
const HISTORY_KEY_ALIASES = {
|
||||
'costco-breathless': 'hotel-breathless',
|
||||
'costco-grand-fiesta': 'hotel-grand-fiesta',
|
||||
@@ -597,6 +691,10 @@ function buildRealtimeSnapshot() {
|
||||
type: 'init',
|
||||
pollsOpen: data.pollsOpen,
|
||||
categories: data.categories,
|
||||
guestRoster: getGuestRoster().map((guest) => ({
|
||||
name: guest.name,
|
||||
role: guest.role || 'guest',
|
||||
})),
|
||||
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
|
||||
results: approvedOptionsWithVoteSummary(),
|
||||
totalVoters: data.voters.length,
|
||||
@@ -627,6 +725,45 @@ function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, ap
|
||||
|
||||
let data = loadData();
|
||||
|
||||
app.get('/api/auth/guests', (req, res) => {
|
||||
res.json({
|
||||
guests: getGuestRoster().map((guest) => ({
|
||||
name: guest.name,
|
||||
role: guest.role || 'guest',
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const guest = verifyGuestToken(readGuestAuthToken(req));
|
||||
if (!guest) {
|
||||
return res.status(401).json({ authenticated: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
authenticated: true,
|
||||
guest,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
const { name, pin } = req.body || {};
|
||||
const guest = findGuestByNameAndPin(name, pin);
|
||||
|
||||
if (!guest) {
|
||||
return res.status(401).json({ error: 'Invalid guest name or code' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token: signGuestToken(guest),
|
||||
guest: {
|
||||
name: guest.name,
|
||||
role: guest.role || 'guest',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/categories', (req, res) => {
|
||||
res.json(data.categories);
|
||||
});
|
||||
@@ -690,11 +827,13 @@ app.get('/api/price-history', (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/vote', (req, res) => {
|
||||
const { optionId, voterName } = req.body;
|
||||
const { optionId } = req.body;
|
||||
const guest = requireGuestAuth(req, res, req.body);
|
||||
|
||||
if (!voterName || !optionId) {
|
||||
if (!optionId) {
|
||||
return res.status(400).json({ error: 'Missing fields' });
|
||||
}
|
||||
if (!guest) return;
|
||||
if (!data.pollsOpen) {
|
||||
return res.status(403).json({ error: 'Polls are closed' });
|
||||
}
|
||||
@@ -706,17 +845,17 @@ app.post('/api/vote', (req, res) => {
|
||||
|
||||
const previousVote = data.options.find((candidate) => (
|
||||
candidate.categoryId === option.categoryId
|
||||
&& candidate.votes.some((vote) => vote.name === voterName)
|
||||
&& candidate.votes.some((vote) => vote.name === guest.name)
|
||||
));
|
||||
|
||||
if (previousVote) {
|
||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
|
||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
|
||||
}
|
||||
|
||||
option.votes.push({ name: voterName, timestamp: Date.now() });
|
||||
option.votes.push({ name: guest.name, timestamp: Date.now() });
|
||||
|
||||
if (!data.voters.find((voter) => voter.name === voterName)) {
|
||||
data.voters.push({ name: voterName, joinedAt: Date.now() });
|
||||
if (!data.voters.find((voter) => voter.name === guest.name)) {
|
||||
data.voters.push({ name: guest.name, joinedAt: Date.now() });
|
||||
}
|
||||
|
||||
saveData(data);
|
||||
@@ -725,23 +864,26 @@ app.post('/api/vote', (req, res) => {
|
||||
});
|
||||
|
||||
app.delete('/api/vote/:optionId', (req, res) => {
|
||||
const { voterName } = req.body;
|
||||
const guest = requireGuestAuth(req, res, req.body);
|
||||
if (!guest) return;
|
||||
const option = data.options.find((candidate) => candidate.id === req.params.optionId);
|
||||
|
||||
if (!option) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
option.votes = option.votes.filter((vote) => vote.name !== voterName);
|
||||
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
|
||||
saveData(data);
|
||||
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/options', (req, res) => {
|
||||
const { categoryId, name, desc, url, voterName, lat, lng } = req.body;
|
||||
const { categoryId, name, desc, url, lat, lng } = req.body;
|
||||
const guest = requireGuestAuth(req, res, req.body);
|
||||
|
||||
if (!categoryId || !name || !voterName) {
|
||||
if (!categoryId || !name) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
if (!guest) return;
|
||||
|
||||
const category = data.categories.find((candidate) => candidate.id === categoryId);
|
||||
if (!category) return res.status(404).json({ error: 'Category not found' });
|
||||
@@ -751,7 +893,7 @@ app.post('/api/options', (req, res) => {
|
||||
name,
|
||||
desc,
|
||||
url,
|
||||
voterName,
|
||||
voterName: guest.name,
|
||||
lat,
|
||||
lng,
|
||||
approved: false,
|
||||
@@ -852,8 +994,12 @@ wss.on('connection', (ws) => {
|
||||
const msg = JSON.parse(raw);
|
||||
|
||||
if (msg.type === 'vote') {
|
||||
const { optionId, voterName, remove } = msg;
|
||||
if (!voterName || !optionId) return;
|
||||
const { optionId, remove } = msg;
|
||||
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
|
||||
if (!guest || !optionId) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
|
||||
return;
|
||||
}
|
||||
if (!data.pollsOpen) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
|
||||
return;
|
||||
@@ -863,35 +1009,39 @@ wss.on('connection', (ws) => {
|
||||
if (!option || !option.approved) return;
|
||||
|
||||
if (remove) {
|
||||
option.votes = option.votes.filter((vote) => vote.name !== voterName);
|
||||
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
|
||||
} else {
|
||||
const previousVote = data.options.find((candidate) => (
|
||||
candidate.categoryId === option.categoryId
|
||||
&& candidate.votes.some((vote) => vote.name === voterName)
|
||||
&& candidate.votes.some((vote) => vote.name === guest.name)
|
||||
));
|
||||
if (previousVote) {
|
||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
|
||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
|
||||
}
|
||||
|
||||
option.votes.push({ name: voterName, timestamp: Date.now() });
|
||||
option.votes.push({ name: guest.name, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
if (!data.voters.find((voter) => voter.name === voterName)) {
|
||||
data.voters.push({ name: voterName, joinedAt: Date.now() });
|
||||
if (!data.voters.find((voter) => voter.name === guest.name)) {
|
||||
data.voters.push({ name: guest.name, joinedAt: Date.now() });
|
||||
}
|
||||
|
||||
saveData(data);
|
||||
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
||||
} else if (msg.type === 'add_option') {
|
||||
const { categoryId, name, desc, url, voterName, lat, lng } = msg;
|
||||
if (!categoryId || !name || !voterName) return;
|
||||
const { categoryId, name, desc, url, lat, lng } = msg;
|
||||
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
|
||||
if (!guest || !categoryId || !name) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const newOption = createUserOption({
|
||||
categoryId,
|
||||
name,
|
||||
desc,
|
||||
url,
|
||||
voterName,
|
||||
voterName: guest.name,
|
||||
lat,
|
||||
lng,
|
||||
approved: true,
|
||||
|
||||
Reference in New Issue
Block a user