From 2912d760cbc7afa358de99bfee4ed8272a3b0f31 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 27 Apr 2026 22:22:42 -0400 Subject: [PATCH] Fix admin seed, open username/email changes, invite refresh & revocation - Seed checks for any admin role instead of username='admin' - Username change open to all registered users (was admin-only) - New change-email endpoint requiring password confirmation - Settings page: inline editing for username and email - Invitations: await refresh after generate so list updates visibly - Invitations: revoke button to delete unused invites (admin only) Co-Authored-By: Claude Opus 4.6 --- apps/server/src/db/seed.ts | 2 +- apps/server/src/routes/auth.ts | 58 ++++++++++- apps/server/src/routes/invites.ts | 22 +++++ apps/web/src/lib/api.ts | 6 ++ apps/web/src/pages/InvitationsPage.tsx | 41 ++++++-- apps/web/src/pages/SettingsPage.tsx | 130 ++++++++++++++++++++++--- 6 files changed, 235 insertions(+), 24 deletions(-) diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts index 194dbc9..e6a5043 100644 --- a/apps/server/src/db/seed.ts +++ b/apps/server/src/db/seed.ts @@ -7,7 +7,7 @@ export async function seedAdmin() { const [existing] = await db .select() .from(users) - .where(eq(users.username, 'admin')) + .where(eq(users.role, 'admin')) .limit(1); if (existing) { diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index e23f7d9..4065ec7 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -152,8 +152,8 @@ auth.post('/change-password', authMiddleware, async (c) => { auth.post('/change-username', authMiddleware, async (c) => { const user = c.get('user'); - if (user.role !== 'admin') { - return c.json({ error: 'Forbidden' }, 403); + if (!user.email && user.role !== 'admin') { + return c.json({ error: 'Must be registered to change username' }, 403); } const { username } = await c.req.json<{ username: string }>(); @@ -180,4 +180,58 @@ auth.post('/change-username', authMiddleware, async (c) => { return c.json({ success: true, token }); }); +auth.post('/change-email', authMiddleware, async (c) => { + const user = c.get('user'); + + if (!user.email && user.role !== 'admin') { + return c.json({ error: 'Must be registered to change email' }, 403); + } + + const { email, currentPassword } = await c.req.json<{ + email: string; + currentPassword: string; + }>(); + + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return c.json({ error: 'Valid email required' }, 400); + } + + if (!currentPassword) { + return c.json({ error: 'Current password required' }, 400); + } + + const [dbUser] = await db + .select({ passwordHash: users.passwordHash }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + if (!dbUser?.passwordHash) { + return c.json({ error: 'No password set' }, 400); + } + + const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash); + if (!valid) { + return c.json({ error: 'Current password is incorrect' }, 401); + } + + const existing = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (existing.length > 0 && existing[0].id !== user.id) { + return c.json({ error: 'Email already in use' }, 409); + } + + await db + .update(users) + .set({ email }) + .where(eq(users.id, user.id)); + + const token = await createToken(user.id, email, user.role, user.username, user.mustResetPassword); + return c.json({ success: true, token }); +}); + export { auth }; diff --git a/apps/server/src/routes/invites.ts b/apps/server/src/routes/invites.ts index a87aa20..4159eee 100644 --- a/apps/server/src/routes/invites.ts +++ b/apps/server/src/routes/invites.ts @@ -141,4 +141,26 @@ invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => { return c.json({ invitations: enriched }); }); +invitesRouter.delete('/:id', authMiddleware, requireAdmin, async (c) => { + const inviteId = c.req.param('id'); + + const [invite] = await db + .select({ id: invitations.id, usedBy: invitations.usedBy }) + .from(invitations) + .where(eq(invitations.id, inviteId)) + .limit(1); + + if (!invite) { + return c.json({ error: 'Invitation not found' }, 404); + } + + if (invite.usedBy) { + return c.json({ error: 'Cannot revoke a used invitation' }, 400); + } + + await db.delete(invitations).where(eq(invitations.id, inviteId)); + + return c.json({ deleted: true }); +}); + export { invitesRouter }; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 1c0cebe..39c17a1 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -135,6 +135,11 @@ export const api = { method: 'POST', body: JSON.stringify({ username }), }), + changeEmail: (email: string, currentPassword: string) => + request<{ success: boolean; token: string }>('/api/auth/change-email', { + method: 'POST', + body: JSON.stringify({ email, currentPassword }), + }), }, config: { get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'), @@ -154,6 +159,7 @@ export const api = { }>; }>('/api/invites'), remaining: () => request<{ remaining: number }>('/api/invites/remaining'), + revoke: (id: string) => request<{ deleted: boolean }>(`/api/invites/${id}`, { method: 'DELETE' }), }, saves: { list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/saves'), diff --git a/apps/web/src/pages/InvitationsPage.tsx b/apps/web/src/pages/InvitationsPage.tsx index 73c1d2e..2d0571a 100644 --- a/apps/web/src/pages/InvitationsPage.tsx +++ b/apps/web/src/pages/InvitationsPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { Copy, Check, Plus, RefreshCw } from 'lucide-react'; +import { Copy, Check, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { api } from '@/lib/api'; interface Invitation { @@ -31,6 +31,7 @@ export function InvitationsPage() { const [loading, setLoading] = useState(true); const [generating, setGenerating] = useState(false); const [copiedCode, setCopiedCode] = useState(null); + const [revoking, setRevoking] = useState(null); const fetchInvitations = useCallback(async () => { try { @@ -53,7 +54,7 @@ export function InvitationsPage() { await navigator.clipboard.writeText(url); setCopiedCode(result.code); setTimeout(() => setCopiedCode(null), 2000); - fetchInvitations(); + await fetchInvitations(); } catch { // silent } finally { @@ -61,6 +62,18 @@ export function InvitationsPage() { } } + async function handleRevoke(id: string) { + setRevoking(id); + try { + await api.invites.revoke(id); + await fetchInvitations(); + } catch { + // silent + } finally { + setRevoking(null); + } + } + async function handleCopyCode(code: string) { const url = `${window.location.origin}?invite=${code}`; await navigator.clipboard.writeText(url); @@ -132,13 +145,23 @@ export function InvitationsPage() { {!inv.used && ( - +
+ + +
)} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 56806a7..8429920 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from 'react'; +import { Pencil, Check, X } from 'lucide-react'; import { useGameStore } from '@/store'; import { ConfirmModal } from '@/components/common/ConfirmModal'; -import { getTokenPayload, isRegistered, isAdmin } from '@/lib/api'; +import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin } from '@/lib/api'; export function SettingsPage() { const settings = useGameStore((s) => s.meta.settings); @@ -12,6 +13,58 @@ export function SettingsPage() { const [showResetConfirm, setShowResetConfirm] = useState(false); const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null); + const [editingUsername, setEditingUsername] = useState(false); + const [usernameValue, setUsernameValue] = useState(''); + const [usernameError, setUsernameError] = useState(''); + const [usernameSaving, setUsernameSaving] = useState(false); + + const [editingEmail, setEditingEmail] = useState(false); + const [emailValue, setEmailValue] = useState(''); + const [emailPassword, setEmailPassword] = useState(''); + const [emailError, setEmailError] = useState(''); + const [emailSaving, setEmailSaving] = useState(false); + + async function handleSaveUsername() { + setUsernameError(''); + if (!usernameValue || usernameValue.length < 2) { + setUsernameError('Username must be at least 2 characters'); + return; + } + setUsernameSaving(true); + try { + const result = await api.auth.changeUsername(usernameValue); + setAuthToken(result.token); + setEditingUsername(false); + } catch (e) { + setUsernameError(e instanceof Error ? e.message : 'Failed to change username'); + } finally { + setUsernameSaving(false); + } + } + + async function handleSaveEmail() { + setEmailError(''); + if (!emailValue || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailValue)) { + setEmailError('Valid email required'); + return; + } + if (!emailPassword) { + setEmailError('Current password required'); + return; + } + setEmailSaving(true); + try { + const result = await api.auth.changeEmail(emailValue, emailPassword); + setAuthToken(result.token); + setEditingEmail(false); + setEmailPassword(''); + } catch (e) { + setEmailError(e instanceof Error ? e.message : 'Failed to change email'); + } finally { + setEmailSaving(false); + } + } + const toggleSound = () => { updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } }); }; @@ -75,23 +128,76 @@ export function SettingsPage() {

Account

{registered ? ( -
- {payload?.email && ( +
+ {payload?.email != null && (
-
+
Email
-
{payload.email}
+ {editingEmail ? ( +
+
+ setEmailValue(e.target.value)} + className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56" + placeholder="New email" + autoFocus + /> + + +
+ setEmailPassword(e.target.value)} + className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56" + placeholder="Current password" + /> + {emailError &&

{emailError}

} +
+ ) : ( +
{payload.email}
+ )}
+ {!editingEmail && ( + + )}
)} - {payload?.username && ( -
-
-
Username
-
{payload.username}
-
+
+
+
Username
+ {editingUsername ? ( +
+
+ setUsernameValue(e.target.value)} + className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-48" + placeholder="Username" + autoFocus + /> + + +
+ {usernameError &&

{usernameError}

} +
+ ) : ( +
{payload?.username ?? 'Not set'}
+ )}
- )} + {!editingUsername && ( + + )} +
{admin && (
Admin -- 2.39.5