[ice] add PIN auth: 2-step name+last4 login gate against groomsmen list
This commit is contained in:
100
client/src/components/NameModal.css
Normal file
100
client/src/components/NameModal.css
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; line-height: 1.5; }
|
||||||
|
|
||||||
|
.modal form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.modal input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.modal input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.modal button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.modal button:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.modal button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.name-error {
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-input {
|
||||||
|
letter-spacing: 0.3em !important;
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
text-align: center !important;
|
||||||
|
font-family: monospace !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal-overlay { align-items: flex-end; justify-content: stretch; padding: 0; }
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
padding: 12px 24px 32px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 -8px 40px rgba(0,0,0,0.6);
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
.drag-handle {
|
||||||
|
width: 40px; height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
}
|
||||||
|
.modal h2 { font-size: 1.15rem; }
|
||||||
|
.modal p { font-size: 0.8rem; margin-bottom: 14px; }
|
||||||
|
}
|
||||||
107
client/src/components/NameModal.jsx
Normal file
107
client/src/components/NameModal.jsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { GROOMSMEN, validateGroomsman } from '../groommen'
|
||||||
|
import './NameModal.css'
|
||||||
|
|
||||||
|
const STORAGE_KEY_PIN = 'cabo_voter_pin'
|
||||||
|
|
||||||
|
export default function NameModal({ onSubmit }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [pin, setPin] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [step, setStep] = useState(1) // 1 = name, 2 = pin
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === 1) inputRef.current?.focus()
|
||||||
|
}, [step])
|
||||||
|
|
||||||
|
const handleNameNext = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) return
|
||||||
|
|
||||||
|
const key = name.trim().toLowerCase().replace(/\s+/g, '')
|
||||||
|
if (!GROOMSMEN[key]) {
|
||||||
|
setError(`"${name}" is not on the guest list. Check with the groom.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
setStep(2)
|
||||||
|
// Pre-fill pin from localStorage if saved
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY_PIN)
|
||||||
|
if (saved) setPin(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePinSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (pin.length !== 4) {
|
||||||
|
setError('Enter the last 4 digits of your phone number.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = name.trim().toLowerCase().replace(/\s+/g, '')
|
||||||
|
if (!validateGroomsman(name, pin)) {
|
||||||
|
setError('Wrong PIN. Make sure you\'re using the correct name + last 4 digits.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY_PIN, pin)
|
||||||
|
onSubmit(name.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep(1)
|
||||||
|
setPin('')
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="drag-handle"></div>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<>
|
||||||
|
<h2>🏄 Who's Voting?</h2>
|
||||||
|
<p>Enter your <strong>full name</strong> as it appears on the guest list.</p>
|
||||||
|
<form onSubmit={handleNameNext}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => { setName(e.target.value); setError('') }}
|
||||||
|
placeholder="e.g. Jon, Toph, Hans…"
|
||||||
|
maxLength={30}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="words"
|
||||||
|
/>
|
||||||
|
{error && <div className="name-error">{error}</div>}
|
||||||
|
<button type="submit" disabled={!name.trim()}>Next →</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2>🔐 Verify with PIN</h2>
|
||||||
|
<p>Enter the <strong>last 4 digits</strong> of your phone number, {name.split(' ')[0]}.</p>
|
||||||
|
<form onSubmit={handlePinSubmit}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={4}
|
||||||
|
value={pin}
|
||||||
|
onChange={e => { setPin(e.target.value.replace(/\D/g, '')); setError('') }}
|
||||||
|
placeholder="••••"
|
||||||
|
autoComplete="off"
|
||||||
|
className="pin-input"
|
||||||
|
/>
|
||||||
|
{error && <div className="name-error">{error}</div>}
|
||||||
|
<button type="submit" disabled={pin.length !== 4}>Join the Vote →</button>
|
||||||
|
<button type="button" className="back-btn" onClick={handleBack}>← Back</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
client/src/groommen.js
Normal file
44
client/src/groommen.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Groomsmen: name (lowercase) → last 4 digits of phone number
|
||||||
|
export const GROOMSMEN = {
|
||||||
|
jon: '7506',
|
||||||
|
toph: '8116',
|
||||||
|
hans: '6681',
|
||||||
|
janno: '2809',
|
||||||
|
jt: '3286',
|
||||||
|
Cordero: '0379', // no last name given
|
||||||
|
lester: '8014',
|
||||||
|
nick: '6044',
|
||||||
|
david: '5993',
|
||||||
|
poalo: '9922', // likely "Paolo"
|
||||||
|
justin: '2329',
|
||||||
|
benstewart: '1957',
|
||||||
|
joseph: '4976',
|
||||||
|
francis: '4934',
|
||||||
|
// Groom
|
||||||
|
chris: '0000', // Chris Mayor — placeholder; update with real last 4 if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse map: last4 → display name
|
||||||
|
export const GROOMSMEN_BY_PIN = Object.fromEntries(
|
||||||
|
Object.entries(GROOMSMEN).map(([name, pin]) => [pin, name])
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a name + 4-digit PIN combination.
|
||||||
|
* Name must match a key in GROOMSMEN (case-insensitive).
|
||||||
|
* PIN must match that entry's value.
|
||||||
|
*/
|
||||||
|
export function validateGroomsman(name, pin) {
|
||||||
|
const key = name.trim().toLowerCase().replace(/\s+/g, '')
|
||||||
|
const entry = GROOMSMEN[key]
|
||||||
|
if (!entry) return false
|
||||||
|
return entry === pin.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the canonical display name for a validated groomsman.
|
||||||
|
*/
|
||||||
|
export function getCanonicalName(name) {
|
||||||
|
const key = name.trim().toLowerCase().replace(/\s+/g, '')
|
||||||
|
return GROOMSMEN[key] ? key : name.trim()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user