Merge pull request 'Fix admin seed, open username/email changes, invite refresh & revocation' (#11) from feature/auth-invites into main
CI / build-and-push (push) Successful in 51s
CI / build-and-push (push) Successful in 51s
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [revoking, setRevoking] = useState<string | null>(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() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{!inv.used && (
|
||||
<button
|
||||
onClick={() => handleCopyCode(inv.code)}
|
||||
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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleCopyCode(inv.code)}
|
||||
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>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
@@ -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() {
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
||||
<h3 className="font-semibold">Account</h3>
|
||||
{registered ? (
|
||||
<div className="space-y-2">
|
||||
{payload?.email && (
|
||||
<div className="space-y-3">
|
||||
{payload?.email != null && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
{!editingEmail && (
|
||||
<button onClick={() => { setEmailValue(payload.email ?? ''); setEditingEmail(true); }}
|
||||
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{payload?.username && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm">Username</div>
|
||||
<div className="text-xs text-surface-400">{payload.username}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm">Username</div>
|
||||
{editingUsername ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
<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>
|
||||
)}
|
||||
{!editingUsername && (
|
||||
<button onClick={() => { setUsernameValue(payload?.username ?? ''); setEditingUsername(true); }}
|
||||
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
{admin && (
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user