Fix admin seed, open username/email changes, invite refresh & revocation #11

Merged
josh merged 1 commits from feature/auth-invites into main 2026-04-27 22:28:24 -04:00
6 changed files with 235 additions and 24 deletions
Showing only changes of commit 2912d760cb - Show all commits
+1 -1
View File
@@ -7,7 +7,7 @@ export async function seedAdmin() {
const [existing] = await db const [existing] = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.username, 'admin')) .where(eq(users.role, 'admin'))
.limit(1); .limit(1);
if (existing) { if (existing) {
+56 -2
View File
@@ -152,8 +152,8 @@ auth.post('/change-password', authMiddleware, async (c) => {
auth.post('/change-username', authMiddleware, async (c) => { auth.post('/change-username', authMiddleware, async (c) => {
const user = c.get('user'); const user = c.get('user');
if (user.role !== 'admin') { if (!user.email && user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403); return c.json({ error: 'Must be registered to change username' }, 403);
} }
const { username } = await c.req.json<{ username: string }>(); 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 }); 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 }; export { auth };
+22
View File
@@ -141,4 +141,26 @@ invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => {
return c.json({ invitations: enriched }); 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 }; export { invitesRouter };
+6
View File
@@ -135,6 +135,11 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ username }), 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: { config: {
get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'), get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'),
@@ -154,6 +159,7 @@ export const api = {
}>; }>;
}>('/api/invites'), }>('/api/invites'),
remaining: () => request<{ remaining: number }>('/api/invites/remaining'), remaining: () => request<{ remaining: number }>('/api/invites/remaining'),
revoke: (id: string) => request<{ deleted: boolean }>(`/api/invites/${id}`, { method: 'DELETE' }),
}, },
saves: { saves: {
list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/saves'), list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/saves'),
+32 -9
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; 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'; import { api } from '@/lib/api';
interface Invitation { interface Invitation {
@@ -31,6 +31,7 @@ export function InvitationsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [copiedCode, setCopiedCode] = useState<string | null>(null); const [copiedCode, setCopiedCode] = useState<string | null>(null);
const [revoking, setRevoking] = useState<string | null>(null);
const fetchInvitations = useCallback(async () => { const fetchInvitations = useCallback(async () => {
try { try {
@@ -53,7 +54,7 @@ export function InvitationsPage() {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
setCopiedCode(result.code); setCopiedCode(result.code);
setTimeout(() => setCopiedCode(null), 2000); setTimeout(() => setCopiedCode(null), 2000);
fetchInvitations(); await fetchInvitations();
} catch { } catch {
// silent // silent
} finally { } 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) { async function handleCopyCode(code: string) {
const url = `${window.location.origin}?invite=${code}`; const url = `${window.location.origin}?invite=${code}`;
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
@@ -132,13 +145,23 @@ export function InvitationsPage() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{!inv.used && ( {!inv.used && (
<button <div className="flex items-center gap-2">
onClick={() => handleCopyCode(inv.code)} <button
className="text-surface-400 hover:text-surface-200 transition-colors" onClick={() => handleCopyCode(inv.code)}
title="Copy invite link" className="text-surface-400 hover:text-surface-200 transition-colors"
> title="Copy invite link"
{copiedCode === inv.code ? <Check size={14} className="text-accent" /> : <Copy size={14} />} >
</button> {copiedCode === inv.code ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
</button>
<button
onClick={() => handleRevoke(inv.id)}
disabled={revoking === inv.id}
className="text-surface-400 hover:text-danger transition-colors disabled:opacity-50"
title="Revoke invitation"
>
<Trash2 size={14} />
</button>
</div>
)} )}
</td> </td>
</tr> </tr>
+118 -12
View File
@@ -1,7 +1,8 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Pencil, Check, X } from 'lucide-react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal'; 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() { export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings); const settings = useGameStore((s) => s.meta.settings);
@@ -12,6 +13,58 @@ export function SettingsPage() {
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null); 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 = () => { const toggleSound = () => {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } }); updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } });
}; };
@@ -75,23 +128,76 @@ export function SettingsPage() {
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Account</h3> <h3 className="font-semibold">Account</h3>
{registered ? ( {registered ? (
<div className="space-y-2"> <div className="space-y-3">
{payload?.email && ( {payload?.email != null && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="flex-1">
<div className="text-sm">Email</div> <div className="text-sm">Email</div>
<div className="text-xs text-surface-400">{payload.email}</div> {editingEmail ? (
<div className="mt-1 space-y-2">
<div className="flex items-center gap-2">
<input
type="email"
value={emailValue}
onChange={(e) => 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
/>
<button onClick={handleSaveEmail} disabled={emailSaving}
className="text-accent hover:text-accent-light disabled:opacity-50"><Check size={16} /></button>
<button onClick={() => { setEditingEmail(false); setEmailError(''); setEmailPassword(''); }}
className="text-surface-400 hover:text-surface-200"><X size={16} /></button>
</div>
<input
type="password"
value={emailPassword}
onChange={(e) => 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 && <p className="text-xs text-danger">{emailError}</p>}
</div>
) : (
<div className="text-xs text-surface-400">{payload.email}</div>
)}
</div> </div>
{!editingEmail && (
<button onClick={() => { setEmailValue(payload.email ?? ''); setEditingEmail(true); }}
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
)}
</div> </div>
)} )}
{payload?.username && ( <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex-1">
<div> <div className="text-sm">Username</div>
<div className="text-sm">Username</div> {editingUsername ? (
<div className="text-xs text-surface-400">{payload.username}</div> <div className="mt-1 space-y-1">
</div> <div className="flex items-center gap-2">
<input
type="text"
value={usernameValue}
onChange={(e) => setUsernameValue(e.target.value)}
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-48"
placeholder="Username"
autoFocus
/>
<button onClick={handleSaveUsername} disabled={usernameSaving}
className="text-accent hover:text-accent-light disabled:opacity-50"><Check size={16} /></button>
<button onClick={() => { setEditingUsername(false); setUsernameError(''); }}
className="text-surface-400 hover:text-surface-200"><X size={16} /></button>
</div>
{usernameError && <p className="text-xs text-danger">{usernameError}</p>}
</div>
) : (
<div className="text-xs text-surface-400">{payload?.username ?? 'Not set'}</div>
)}
</div> </div>
)} {!editingUsername && (
<button onClick={() => { setUsernameValue(payload?.username ?? ''); setEditingUsername(true); }}
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
)}
</div>
{admin && ( {admin && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent font-medium">Admin</span> <span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent font-medium">Admin</span>