feat(auth): guest login with random telegraph operator name

No email required — players pick male/female/mystery, get assigned
a humorous period-appropriate operator name (e.g. "Dusty Clicksworth",
"Ada Dottsworth", "The Phantom Sender"), can reroll, then hit
"Begin Transmitting!" for instant play. Email/magic link moved to
a collapsible section for cross-device access only.

- server: POST /api/auth/guest creates guest profile + session
- server/db: profiles.email now nullable, is_guest column added,
  migration recreates table for existing installs
- client: names.ts with 15 names per gender category
- client: LoginPage redesigned — gender picker → name display → play
- client: useAuth gains setAuth() for direct session injection
- 55 server + 44 client tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 04:22:05 +00:00
parent 8232e75fa9
commit fe5cbaec2b
7 changed files with 341 additions and 39 deletions
+5 -2
View File
@@ -9,7 +9,7 @@ import './index.css'
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin' type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
export default function App() { export default function App() {
const { profile, token, loading, logout } = useAuth() const { profile, token, loading, logout, setAuth } = useAuth()
const [route, setRoute] = useState<AppRoute>('login') const [route, setRoute] = useState<AppRoute>('login')
const [checkingOnboarding, setCheckingOnboarding] = useState(false) const [checkingOnboarding, setCheckingOnboarding] = useState(false)
@@ -49,7 +49,10 @@ export default function App() {
return ( return (
<div className="app-shell"> <div className="app-shell">
{route === 'login' && ( {route === 'login' && (
<LoginPage onLoginSent={() => setRoute('sent')} /> <LoginPage
onLoginSent={() => setRoute('sent')}
onGuestLogin={(token, profile) => setAuth(token, profile)}
/>
)} )}
{route === 'sent' && ( {route === 'sent' && (
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}> <div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
+13 -2
View File
@@ -1,6 +1,11 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
type Profile = { id: number; email: string; display_name: string | null } export type Profile = {
id: number
email: string | null
display_name: string | null
is_guest: boolean
}
type AuthState = { profile: Profile | null; token: string | null; loading: boolean } type AuthState = { profile: Profile | null; token: string | null; loading: boolean }
export function useAuth() { export function useAuth() {
@@ -50,5 +55,11 @@ export function useAuth() {
setState({ profile: null, token: null, loading: false }) setState({ profile: null, token: null, loading: false })
}, []) }, [])
return { ...state, logout } // Used by guest login to inject a session without a round-trip
const setAuth = useCallback((token: string, profile: Profile) => {
localStorage.setItem('session', token)
setState({ profile, token, loading: false })
}, [])
return { ...state, logout, setAuth }
} }
+60
View File
@@ -0,0 +1,60 @@
export type Gender = 'male' | 'female' | 'mystery'
export const OPERATOR_NAMES: Record<Gender, string[]> = {
male: [
'Dusty Clicksworth',
'Buck Dittley',
'Flash Henderson',
'Smokey McBeep',
'Knuckles McGee',
'Leadfoot Larry Tapsworth',
'Jittery Jack Clicker',
"Two-Finger Tex",
'Wild Bill Keyer',
'Rusty Tapper',
'Cactus Pete Signalton',
'Ol\' Sparky Johnson',
'Slick Fingersby',
'Gabby Dan Dashmore',
'Young Buck Beepsworth',
],
female: [
'Ada Dottsworth',
'Clementine Dashford',
'Pearl Signalton',
'Nellie Tapsworth',
'Hattie Clickmore',
'Violet Waverley',
'Mabel Keymoor',
'Belle Dashmore',
'Ruby Signalbright',
'Clara Quick-Keys',
'Ethel Clicksworth',
'Dot O\'Dashes',
'Hazel Transmitter',
'Iris Tapford',
'Goldie Wavemore',
],
mystery: [
'The Phantom Sender',
'Agent Dot-Dash',
'X. Marconi',
'The Ghost Operator',
'Shadow Keyer',
'The Unknown Transmitter',
'Agent Zero-Zero',
'The Masked Tapper',
'Signal Unknown',
'The Wandering Wave',
'Cipher McGee',
'The Midnight Sender',
'Static McFrequency',
'Professor Dash',
'The Lone Transmitter',
],
}
export function randomName(gender: Gender): string {
const list = OPERATOR_NAMES[gender]
return list[Math.floor(Math.random() * list.length)]
}
+184 -34
View File
@@ -1,16 +1,60 @@
import { useState } from 'react' import { useState } from 'react'
import type { Profile } from '../hooks/useAuth'
import { type Gender, randomName } from '../lib/names'
type LoginPageProps = { onLoginSent: () => void } type Props = {
onLoginSent: () => void
onGuestLogin: (token: string, profile: Profile) => void
}
export function LoginPage({ onLoginSent }: LoginPageProps) { const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: '♂' },
{ value: 'female', label: 'Female', icon: '♀' },
{ value: 'mystery', label: 'Mystery', icon: '✦' },
]
export function LoginPage({ onLoginSent, onGuestLogin }: Props) {
const [gender, setGender] = useState<Gender | null>(null)
const [name, setName] = useState('')
const [joining, setJoining] = useState(false)
const [showEmail, setShowEmail] = useState(false)
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) { function pickGender(g: Gender) {
setGender(g)
setName(randomName(g))
setError(null)
}
function reroll() {
if (gender) setName(randomName(gender))
}
async function startPlaying() {
if (!name) return
setJoining(true)
setError(null)
try {
const r = await fetch('/api/auth/guest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: name }),
})
if (!r.ok) throw new Error('Failed to create session')
const { token, profile } = await r.json()
onGuestLogin(token, profile)
} catch {
setError('Something went wrong. Try again.')
setJoining(false)
}
}
async function sendMagicLink(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!email.includes('@') || !email.includes('.')) { if (!email.includes('@') || !email.includes('.')) {
setError('Please enter a valid email address.') setError('Enter a valid email address.')
return return
} }
setSubmitting(true) setSubmitting(true)
@@ -21,44 +65,150 @@ export function LoginPage({ onLoginSent }: LoginPageProps) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
}) })
if (!r.ok) { if (!r.ok) throw new Error('Failed to send link')
const body = await r.json().catch(() => ({}))
throw new Error(body.error || 'Something went wrong')
}
onLoginSent() onLoginSent()
} catch (err: unknown) { } catch {
setError(err instanceof Error ? err.message : 'Something went wrong') setError('Failed to send magic link. Try again.')
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
} }
return ( return (
<div style={{ maxWidth: 420, margin: '4rem auto 0' }}> <div style={{ maxWidth: 440, margin: '3rem auto 0', padding: '0 1rem' }}>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>MorseQuest</h1> <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<p style={{ marginBottom: '2rem', color: 'var(--color-brown)' }}> <h1 style={{ fontFamily: 'var(--font-header)', fontSize: '2rem', color: 'var(--color-forest)', marginBottom: '0.25rem' }}>
Learn Morse code, adventurer. Enter your email to begin your quest. Join the Telegraph Corps
</p> </h1>
<form onSubmit={handleSubmit}> <p style={{ color: 'var(--color-brown)', fontSize: '0.95rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}> No sign-up needed pick your operator and start transmitting!
Email address </p>
</label> </div>
<input
type="email" {/* Step 1: Gender picker */}
value={email} <div style={{ marginBottom: '1.5rem' }}>
onChange={e => setEmail(e.target.value)} <p style={{ fontWeight: 600, marginBottom: '0.6rem', textAlign: 'center' }}>
placeholder="you@example.com" Choose your operator type:
disabled={submitting} </p>
required <div style={{ display: 'flex', gap: '0.75rem' }}>
style={{ marginBottom: '1rem' }} {GENDER_OPTIONS.map(opt => (
/> <button
{error && ( key={opt.value}
<p style={{ color: 'crimson', marginBottom: '1rem', fontSize: '0.9rem' }}>{error}</p> type="button"
)} onClick={() => pickGender(opt.value)}
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}> style={{
{submitting ? 'Sending...' : 'Send Magic Link'} flex: 1,
padding: '0.7rem 0.5rem',
fontFamily: 'var(--font-header)',
fontSize: '1rem',
background: gender === opt.value ? 'var(--color-gold)' : 'var(--color-parchment)',
border: `2px solid ${gender === opt.value ? 'var(--color-gold)' : '#c8a96e'}`,
borderRadius: '0.4rem',
cursor: 'pointer',
color: 'var(--color-forest)',
fontWeight: gender === opt.value ? 700 : 400,
transition: 'all 0.15s',
}}
>
<div style={{ fontSize: '1.4rem' }}>{opt.icon}</div>
{opt.label}
</button>
))}
</div>
</div>
{/* Step 2: Name + reroll + start */}
{gender && (
<div style={{ marginBottom: '1.5rem' }}>
<p style={{ fontWeight: 600, marginBottom: '0.5rem', textAlign: 'center' }}>
Your operator name:
</p>
<div style={{
background: 'var(--color-parchment)',
border: '2px solid var(--color-gold)',
borderRadius: '0.4rem',
padding: '0.9rem 1rem',
textAlign: 'center',
marginBottom: '0.75rem',
}}>
<span style={{
fontFamily: 'var(--font-header)',
fontSize: '1.3rem',
color: 'var(--color-forest)',
fontWeight: 700,
}}>
{name}
</span>
</div>
<div style={{ display: 'flex', gap: '0.6rem' }}>
<button
type="button"
onClick={reroll}
disabled={joining}
className="btn btn-ghost"
style={{ whiteSpace: 'nowrap' }}
>
Different name
</button>
<button
type="button"
onClick={startPlaying}
disabled={joining}
className="btn"
style={{ flex: 1, fontSize: '1.05rem' }}
>
{joining ? 'Joining...' : 'Begin Transmitting!'}
</button>
</div>
</div>
)}
{error && (
<p style={{ color: 'crimson', fontSize: '0.9rem', textAlign: 'center', marginBottom: '1rem' }}>
{error}
</p>
)}
{/* Optional email section */}
<div style={{ borderTop: '1px solid #c8a96e', paddingTop: '1rem', marginTop: '0.5rem' }}>
<button
type="button"
onClick={() => { setShowEmail(v => !v); setError(null) }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-brown)',
fontSize: '0.9rem',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
margin: '0 auto',
}}
>
<span>{showEmail ? '▾' : '▸'}</span>
Already have an account or want cross-device access?
</button> </button>
</form>
{showEmail && (
<form onSubmit={sendMagicLink} style={{ marginTop: '0.75rem' }}>
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem', textAlign: 'center' }}>
Enter your email to receive a magic link.
</p>
<input
type="email"
value={email}
onChange={e => { setEmail(e.target.value); setError(null) }}
placeholder="telegraph@example.com"
disabled={submitting}
style={{ marginBottom: '0.5rem' }}
/>
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}>
{submitting ? 'Sending...' : 'Send Magic Link'}
</button>
</form>
)}
</div>
</div> </div>
) )
} }
+31 -1
View File
@@ -8,11 +8,33 @@ function initDb(dbPath) {
instance.pragma('journal_mode = WAL') instance.pragma('journal_mode = WAL')
instance.pragma('foreign_keys = ON') instance.pragma('foreign_keys = ON')
// Migration: add is_guest + make email nullable on existing installs
const profileCols = instance.pragma('table_info(profiles)').map(c => c.name)
if (profileCols.length > 0 && !profileCols.includes('is_guest')) {
instance.pragma('foreign_keys = OFF')
instance.exec(`
CREATE TABLE profiles_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE,
display_name TEXT,
is_guest INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
INSERT INTO profiles_v2 (id, email, display_name, is_guest, created_at, last_seen)
SELECT id, email, display_name, 0, created_at, last_seen FROM profiles;
DROP TABLE profiles;
ALTER TABLE profiles_v2 RENAME TO profiles;
`)
instance.pragma('foreign_keys = ON')
}
instance.exec(` instance.exec(`
CREATE TABLE IF NOT EXISTS profiles ( CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE,
display_name TEXT, display_name TEXT,
is_guest INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL last_seen INTEGER NOT NULL
); );
@@ -56,6 +78,13 @@ function initDb(dbPath) {
); );
`) `)
function createGuestProfile(displayName) {
const now = Date.now()
return instance.prepare(
'INSERT INTO profiles (email, display_name, is_guest, created_at, last_seen) VALUES (NULL,?,1,?,?) RETURNING *'
).get(displayName, now, now)
}
function createProfile(email, displayName) { function createProfile(email, displayName) {
const now = Date.now() const now = Date.now()
const stmt = instance.prepare( const stmt = instance.prepare(
@@ -230,6 +259,7 @@ function initDb(dbPath) {
return { return {
_db: instance, _db: instance,
createGuestProfile,
createProfile, createProfile,
findProfileByEmail, findProfileByEmail,
findProfileById, findProfileById,
+17
View File
@@ -62,6 +62,22 @@ function createServer(db) {
// ── Auth routes ────────────────────────────────────────────────────────── // ── Auth routes ──────────────────────────────────────────────────────────
if (pathname === '/api/auth/guest' && method === 'POST') {
const body = await readBody(req)
const name = body.display_name
if (!name || typeof name !== 'string' || name.trim().length < 2 || name.trim().length > 80) {
return send(res, 400, { error: 'display_name required (2-80 chars)' })
}
const profile = db.createGuestProfile(name.trim())
db.getOrCreateProgress(profile.id)
const sessionToken = crypto.randomBytes(32).toString('hex')
db.createSession(sessionToken, profile.id)
return send(res, 200, {
token: sessionToken,
profile: { id: profile.id, email: null, display_name: profile.display_name, is_guest: true },
})
}
if (pathname === '/api/auth/request' && method === 'POST') { if (pathname === '/api/auth/request' && method === 'POST') {
const body = await readBody(req) const body = await readBody(req)
const email = body.email const email = body.email
@@ -102,6 +118,7 @@ function createServer(db) {
id: profile.id, id: profile.id,
email: profile.email, email: profile.email,
display_name: profile.display_name, display_name: profile.display_name,
is_guest: profile.is_guest === 1,
}) })
} }
+31
View File
@@ -43,6 +43,37 @@ after(() => {
return new Promise(resolve => server.close(resolve)) return new Promise(resolve => server.close(resolve))
}) })
describe('POST /api/auth/guest', () => {
test('creates guest profile and returns token + profile', async () => {
const r = await request(server, 'POST', '/api/auth/guest', { display_name: 'Dusty Clicksworth' })
assert.equal(r.status, 200)
assert.ok(r.body.token)
assert.equal(r.body.profile.display_name, 'Dusty Clicksworth')
assert.equal(r.body.profile.is_guest, true)
assert.equal(r.body.profile.email, null)
})
test('session token from guest login works with /api/auth/me', async () => {
const g = await request(server, 'POST', '/api/auth/guest', { display_name: 'Flash Henderson' })
const me = await request(server, 'GET', '/api/auth/me', null, {
Authorization: `Bearer ${g.body.token}`,
})
assert.equal(me.status, 200)
assert.equal(me.body.display_name, 'Flash Henderson')
assert.equal(me.body.is_guest, true)
})
test('returns 400 if display_name missing', async () => {
const r = await request(server, 'POST', '/api/auth/guest', {})
assert.equal(r.status, 400)
})
test('returns 400 if display_name too short', async () => {
const r = await request(server, 'POST', '/api/auth/guest', { display_name: 'X' })
assert.equal(r.status, 400)
})
})
describe('POST /api/auth/request', () => { describe('POST /api/auth/request', () => {
test('returns 200 ok for valid email', async () => { test('returns 200 ok for valid email', async () => {
const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' }) const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' })