From fe5cbaec2b4821b4ac64932714c190a39dac07ef Mon Sep 17 00:00:00 2001 From: kitadmin Date: Thu, 30 Apr 2026 04:22:05 +0000 Subject: [PATCH] feat(auth): guest login with random telegraph operator name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.tsx | 7 +- client/src/hooks/useAuth.ts | 15 ++- client/src/lib/names.ts | 60 +++++++++ client/src/pages/LoginPage.tsx | 218 ++++++++++++++++++++++++++++----- server/db.js | 32 ++++- server/server.js | 17 +++ server/tests/auth.test.js | 31 +++++ 7 files changed, 341 insertions(+), 39 deletions(-) create mode 100644 client/src/lib/names.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 78a6cc7..899eb8a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,7 +9,7 @@ import './index.css' type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin' export default function App() { - const { profile, token, loading, logout } = useAuth() + const { profile, token, loading, logout, setAuth } = useAuth() const [route, setRoute] = useState('login') const [checkingOnboarding, setCheckingOnboarding] = useState(false) @@ -49,7 +49,10 @@ export default function App() { return (
{route === 'login' && ( - setRoute('sent')} /> + setRoute('sent')} + onGuestLogin={(token, profile) => setAuth(token, profile)} + /> )} {route === 'sent' && (
diff --git a/client/src/hooks/useAuth.ts b/client/src/hooks/useAuth.ts index 4ce2f19..a8e17f1 100644 --- a/client/src/hooks/useAuth.ts +++ b/client/src/hooks/useAuth.ts @@ -1,6 +1,11 @@ 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 } export function useAuth() { @@ -50,5 +55,11 @@ export function useAuth() { 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 } } diff --git a/client/src/lib/names.ts b/client/src/lib/names.ts new file mode 100644 index 0000000..696d251 --- /dev/null +++ b/client/src/lib/names.ts @@ -0,0 +1,60 @@ +export type Gender = 'male' | 'female' | 'mystery' + +export const OPERATOR_NAMES: Record = { + 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)] +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 3666d3f..c7287eb 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,16 +1,60 @@ 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(null) + const [name, setName] = useState('') + const [joining, setJoining] = useState(false) + const [showEmail, setShowEmail] = useState(false) const [email, setEmail] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(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() if (!email.includes('@') || !email.includes('.')) { - setError('Please enter a valid email address.') + setError('Enter a valid email address.') return } setSubmitting(true) @@ -21,44 +65,150 @@ export function LoginPage({ onLoginSent }: LoginPageProps) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }) - if (!r.ok) { - const body = await r.json().catch(() => ({})) - throw new Error(body.error || 'Something went wrong') - } + if (!r.ok) throw new Error('Failed to send link') onLoginSent() - } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Something went wrong') + } catch { + setError('Failed to send magic link. Try again.') } finally { setSubmitting(false) } } return ( -
-

MorseQuest

-

- Learn Morse code, adventurer. Enter your email to begin your quest. -

-
- - setEmail(e.target.value)} - placeholder="you@example.com" - disabled={submitting} - required - style={{ marginBottom: '1rem' }} - /> - {error && ( -

{error}

- )} - + ))} +
+
+ + {/* Step 2: Name + reroll + start */} + {gender && ( +
+

+ Your operator name: +

+
+ + {name} + +
+
+ + +
+
+ )} + + {error && ( +

+ {error} +

+ )} + + {/* Optional email section */} +
+ - + + {showEmail && ( +
+

+ Enter your email to receive a magic link. +

+ { setEmail(e.target.value); setError(null) }} + placeholder="telegraph@example.com" + disabled={submitting} + style={{ marginBottom: '0.5rem' }} + /> + +
+ )} +
) } diff --git a/server/db.js b/server/db.js index c3d96fb..30dbf96 100644 --- a/server/db.js +++ b/server/db.js @@ -8,11 +8,33 @@ function initDb(dbPath) { instance.pragma('journal_mode = WAL') 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(` CREATE TABLE IF NOT EXISTS profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT UNIQUE NOT NULL, + email TEXT UNIQUE, display_name TEXT, + is_guest INTEGER NOT NULL DEFAULT 0, created_at 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) { const now = Date.now() const stmt = instance.prepare( @@ -230,6 +259,7 @@ function initDb(dbPath) { return { _db: instance, + createGuestProfile, createProfile, findProfileByEmail, findProfileById, diff --git a/server/server.js b/server/server.js index e8b3c65..964de62 100644 --- a/server/server.js +++ b/server/server.js @@ -62,6 +62,22 @@ function createServer(db) { // ── 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') { const body = await readBody(req) const email = body.email @@ -102,6 +118,7 @@ function createServer(db) { id: profile.id, email: profile.email, display_name: profile.display_name, + is_guest: profile.is_guest === 1, }) } diff --git a/server/tests/auth.test.js b/server/tests/auth.test.js index 0f98017..4f4905a 100644 --- a/server/tests/auth.test.js +++ b/server/tests/auth.test.js @@ -43,6 +43,37 @@ after(() => { 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', () => { test('returns 200 ok for valid email', async () => { const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' })