Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce2cab3404 | |||
| 2912d760cb |
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,6 +145,7 @@ export function InvitationsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{!inv.used && (
|
{!inv.used && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopyCode(inv.code)}
|
onClick={() => handleCopyCode(inv.code)}
|
||||||
className="text-surface-400 hover:text-surface-200 transition-colors"
|
className="text-surface-400 hover:text-surface-200 transition-colors"
|
||||||
@@ -139,6 +153,15 @@ export function InvitationsPage() {
|
|||||||
>
|
>
|
||||||
{copiedCode === inv.code ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
|
{copiedCode === inv.code ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{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 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>
|
<div className="flex-1">
|
||||||
<div className="text-sm">Username</div>
|
<div className="text-sm">Username</div>
|
||||||
<div className="text-xs text-surface-400">{payload.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>
|
</div>
|
||||||
|
{usernameError && <p className="text-xs text-danger">{usernameError}</p>}
|
||||||
</div>
|
</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 && (
|
{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>
|
||||||
|
|||||||
Reference in New Issue
Block a user