Skip to content

Commit 1d02408

Browse files
committed
feat(auth): ✨ add super admin role and admin management
1 parent 2ee3daf commit 1d02408

5 files changed

Lines changed: 214 additions & 57 deletions

File tree

client/src/context/AuthContext.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createContext, type ReactNode, useContext, useEffect, useState } from '
22

33
interface AuthCtx {
44
isAdmin: boolean;
5+
isSuperAdmin: boolean;
56
checking: boolean;
67
login: (username: string, password: string) => Promise<string | null>;
78
logout: () => Promise<void>;
@@ -12,6 +13,7 @@ const Ctx = createContext<AuthCtx>({} as AuthCtx);
1213

1314
export function AuthProvider({ children }: { children: ReactNode }) {
1415
const [isAdmin, setIsAdmin] = useState(false);
16+
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
1517
const [checking, setChecking] = useState(true);
1618
const [token, setToken] = useState<string | null>(() => localStorage.getItem('adminToken'));
1719

@@ -26,7 +28,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
2628
headers: { Authorization: `Bearer ${t}` },
2729
});
2830
if (res.ok) {
31+
const data = await res.json();
2932
setIsAdmin(true);
33+
setIsSuperAdmin(data.isSuperAdmin ?? false);
3034
setToken(t);
3135
} else {
3236
localStorage.removeItem('adminToken');
@@ -44,15 +48,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
4448
});
4549
const data = await res.json();
4650
if (res.ok) {
47-
// Extract token from cookie echo — server sets httpOnly cookie AND we
48-
// also store it in localStorage for socket auth
49-
// Re-fetch /me to get a bearer token flow
50-
// Actually: let's ask the server for a token response
51-
// We update the server to also return the token in the body
5251
const t = data.token as string;
5352
localStorage.setItem('adminToken', t);
5453
setToken(t);
5554
setIsAdmin(true);
55+
// Fetch /me to get isSuperAdmin status
56+
const meRes = await fetch('/api/admin/me', {
57+
headers: { Authorization: `Bearer ${t}` },
58+
});
59+
if (meRes.ok) {
60+
const meData = await meRes.json();
61+
setIsSuperAdmin(meData.isSuperAdmin ?? false);
62+
}
5663
return null;
5764
}
5865
return data.error ?? 'Login failed';
@@ -63,10 +70,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
6370
localStorage.removeItem('adminToken');
6471
setToken(null);
6572
setIsAdmin(false);
73+
setIsSuperAdmin(false);
6674
}
6775

6876
return (
69-
<Ctx.Provider value={{ isAdmin, checking, login, logout, token }}>{children}</Ctx.Provider>
77+
<Ctx.Provider value={{ isAdmin, isSuperAdmin, checking, login, logout, token }}>
78+
{children}
79+
</Ctx.Provider>
7080
);
7181
}
7282

client/src/pages/admin/Settings.tsx

Lines changed: 132 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function SpeedBonusPreview({ max, min }: { max: number; min: number }) {
5353
}
5454

5555
export default function Settings() {
56-
const { token } = useAuth();
56+
const { token, isSuperAdmin } = useAuth();
5757
const [cfg, setCfg] = useState<Partial<AppConfig> | null>(null);
5858
const [saved, setSaved] = useState(false);
5959
const [saving, setSaving] = useState(false);
@@ -67,6 +67,13 @@ export default function Settings() {
6767
const [availableAvatars, setAvailableAvatars] = useState<string[] | null>(null);
6868
const [avatarsError, setAvatarsError] = useState<string | null>(null);
6969

70+
// Super admin: admin management
71+
const [dbAdmins, setDbAdmins] = useState<
72+
{ id: number; username: string; created_at: string; last_password_change: string | null }[]
73+
>([]);
74+
const [resetPasswords, setResetPasswords] = useState<Record<number, string>>({});
75+
const [resettingId, setResettingId] = useState<number | null>(null);
76+
7077
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
7178

7279
useEffect(() => {
@@ -93,7 +100,16 @@ export default function Settings() {
93100
setAvailableAvatars([]);
94101
setAvatarsError('Could not load current avatars.');
95102
});
96-
}, [token]);
103+
104+
if (isSuperAdmin) {
105+
fetch('/api/admin/admins', { headers: { Authorization: `Bearer ${token}` } })
106+
.then((r) => r.json())
107+
.then((data) => {
108+
if (data.admins) setDbAdmins(data.admins);
109+
})
110+
.catch(() => {});
111+
}
112+
}, [token, isSuperAdmin]);
97113

98114
async function changePassword() {
99115
if (!currentPassword) {
@@ -393,51 +409,124 @@ export default function Settings() {
393409
</div>
394410

395411
{/* Admin credentials */}
396-
<div className="card">
397-
<h2 className="mb-4">🔐 Admin Credentials</h2>
398-
<p className="text-sm text-muted mb-4">
399-
Current username: <strong style={{ color: 'var(--text)' }}>{adminUsername || '...'}</strong>
400-
</p>
412+
{isSuperAdmin ? (
413+
<div className="card">
414+
<h2 className="mb-4">🔐 Admin Management (Super Admin)</h2>
415+
<div className="alert alert-warn mb-4" style={{ fontSize: '0.85rem' }}>
416+
You are logged in as the super admin. Your password is managed via the
417+
ADMIN_PASSWORD environment variable.
418+
</div>
401419

402-
<div className="form-group">
403-
<Input
404-
label="Current Password (required to make changes)"
405-
type="password"
406-
autoComplete="off"
407-
value={currentPassword}
408-
onChange={(e) => setCurrentPassword(e.target.value)}
409-
placeholder="Enter current password"
410-
/>
411-
<Input
412-
label="New Username (leave blank to keep current)"
413-
autoComplete="off"
414-
value={newUsername}
415-
onChange={(e) => setNewUsername(e.target.value)}
416-
placeholder={adminUsername || 'admin'}
417-
/>
418-
<Input
419-
label="New Password (leave blank to keep current)"
420-
type="password"
421-
autoComplete="new-password"
422-
value={newPassword}
423-
onChange={(e) => setNewPassword(e.target.value)}
424-
placeholder="Enter new password"
425-
noMargin
426-
/>
427-
<button
428-
type="button"
429-
onClick={changePassword}
430-
disabled={changingPassword}
431-
className="btn btn-primary mt-3"
432-
>
433-
{changingPassword ? 'Updating...' : 'Update Credentials'}
434-
</button>
420+
{dbAdmins.length === 0 ? (
421+
<p className="text-sm text-muted">No database admins found.</p>
422+
) : (
423+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
424+
{dbAdmins.map((admin) => (
425+
<div
426+
key={admin.id}
427+
style={{
428+
display: 'flex',
429+
alignItems: 'center',
430+
gap: 12,
431+
padding: '10px 14px',
432+
background: 'var(--surface2)',
433+
border: '1px solid var(--border)',
434+
borderRadius: 'var(--radius-sm)',
435+
flexWrap: 'wrap',
436+
}}
437+
>
438+
<strong style={{ flex: 1, minWidth: 100 }}>{admin.username}</strong>
439+
<Input
440+
label=""
441+
type="password"
442+
autoComplete="new-password"
443+
placeholder="New password"
444+
value={resetPasswords[admin.id] || ''}
445+
onChange={(e) =>
446+
setResetPasswords((prev) => ({ ...prev, [admin.id]: e.target.value }))
447+
}
448+
noMargin
449+
/>
450+
<button
451+
type="button"
452+
className="btn btn-primary"
453+
disabled={resettingId === admin.id || !resetPasswords[admin.id]}
454+
onClick={async () => {
455+
setResettingId(admin.id);
456+
try {
457+
const res = await fetch(`/api/admin/admins/${admin.id}/reset-password`, {
458+
method: 'POST',
459+
headers,
460+
body: JSON.stringify({ newPassword: resetPasswords[admin.id] }),
461+
});
462+
if (res.ok) {
463+
alert(`Password reset for ${admin.username}`);
464+
setResetPasswords((prev) => ({ ...prev, [admin.id]: '' }));
465+
} else {
466+
const err = await res.json();
467+
alert(err.error || 'Reset failed');
468+
}
469+
} catch {
470+
alert('Reset failed');
471+
} finally {
472+
setResettingId(null);
473+
}
474+
}}
475+
>
476+
{resettingId === admin.id ? 'Resetting...' : 'Reset Password'}
477+
</button>
478+
</div>
479+
))}
480+
</div>
481+
)}
435482
</div>
483+
) : (
484+
<div className="card">
485+
<h2 className="mb-4">🔐 Admin Credentials</h2>
486+
<p className="text-sm text-muted mb-4">
487+
Current username: <strong style={{ color: 'var(--text)' }}>{adminUsername || '...'}</strong>
488+
</p>
436489

437-
<div className="alert alert-warn mt-4" style={{ fontSize: '0.85rem' }}>
438-
⚠️ After changing credentials, you&apos;ll be logged out and need to log back in.
490+
<div className="form-group">
491+
<Input
492+
label="Current Password (required to make changes)"
493+
type="password"
494+
autoComplete="off"
495+
value={currentPassword}
496+
onChange={(e) => setCurrentPassword(e.target.value)}
497+
placeholder="Enter current password"
498+
/>
499+
<Input
500+
label="New Username (leave blank to keep current)"
501+
autoComplete="off"
502+
value={newUsername}
503+
onChange={(e) => setNewUsername(e.target.value)}
504+
placeholder={adminUsername || 'admin'}
505+
/>
506+
<Input
507+
label="New Password (leave blank to keep current)"
508+
type="password"
509+
autoComplete="new-password"
510+
value={newPassword}
511+
onChange={(e) => setNewPassword(e.target.value)}
512+
placeholder="Enter new password"
513+
noMargin
514+
/>
515+
<button
516+
type="button"
517+
onClick={changePassword}
518+
disabled={changingPassword}
519+
className="btn btn-primary mt-3"
520+
>
521+
{changingPassword ? 'Updating...' : 'Update Credentials'}
522+
</button>
523+
</div>
524+
525+
<div className="alert alert-warn mt-4" style={{ fontSize: '0.85rem' }}>
526+
⚠️ After changing credentials, you&apos;ll be logged out and need to log back in.
527+
</div>
439528
</div>
440-
</div>
529+
)}
441530

442531
{/* Avatar / emoji library */}
443532
<div className="card">

server/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
"version": "1.0.0",
44
"private": true,
55
"scripts": {
6-
"dev": "tsx watch src/index.ts",
6+
"dev": "tsx watch --env-file=../.env src/index.ts",
77
"build": "tsc",
8-
"start": "node dist/index.js",
8+
"start": "node --env-file=../.env dist/index.js",
99
"typecheck": "tsc --noEmit",
1010
"lint": "biome lint src"
1111
},

server/src/middleware.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,16 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction): v
99
return;
1010
}
1111
try {
12-
const decoded = jwt.verify(token, config.jwtSecret) as { username: string; adminId: number };
13-
(req as any).user = { username: decoded.username, adminId: decoded.adminId };
12+
const decoded = jwt.verify(token, config.jwtSecret) as {
13+
username: string;
14+
adminId: number;
15+
isSuperAdmin?: boolean;
16+
};
17+
(req as any).user = {
18+
username: decoded.username,
19+
adminId: decoded.adminId,
20+
isSuperAdmin: decoded.isSuperAdmin ?? false,
21+
};
1422
next();
1523
} catch {
1624
res.status(401).json({ error: 'Invalid or expired token' });

server/src/routes/admin.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,19 @@ adminRouter.post('/login', async (req: Request, res: Response) => {
1414
const { username, password } = req.body as { username: string; password: string };
1515

1616
try {
17-
// Check database for admin
17+
// 1. Check env super-admin first (never stored in DB)
18+
const envUser = process.env.ADMIN_USERNAME || 'admin';
19+
const envPass = process.env.ADMIN_PASSWORD;
20+
if (envPass && username === envUser && password === envPass) {
21+
const token = jwt.sign(
22+
{ username: envUser, adminId: 0, isSuperAdmin: true },
23+
config.jwtSecret,
24+
{ expiresIn: '7d' },
25+
);
26+
return res.json({ token });
27+
}
28+
29+
// 2. Check database for admin
1830
const admin = await db.get('SELECT * FROM admins WHERE username = ?', [username]);
1931

2032
if (!admin) {
@@ -47,6 +59,12 @@ adminRouter.post('/logout', (_req, res) => {
4759
});
4860

4961
adminRouter.post('/change-password', requireAdmin, async (req: Request, res: Response) => {
62+
if ((req as any).user.isSuperAdmin) {
63+
return res
64+
.status(400)
65+
.json({ error: 'Super admin password is managed via environment variables' });
66+
}
67+
5068
const { currentPassword, newPassword, newUsername } = req.body as {
5169
currentPassword: string;
5270
newPassword?: string;
@@ -96,9 +114,41 @@ adminRouter.post('/change-password', requireAdmin, async (req: Request, res: Res
96114
});
97115

98116
adminRouter.get('/me', requireAdmin, async (req, res) => {
99-
const adminId = (req as any).user.adminId;
100-
const admin = await db.get('SELECT username FROM admins WHERE id = ?', [adminId]);
101-
res.json({ ok: true, username: admin?.username });
117+
const user = (req as any).user;
118+
if (user.isSuperAdmin) {
119+
return res.json({ ok: true, username: user.username, isSuperAdmin: true });
120+
}
121+
const admin = await db.get('SELECT username FROM admins WHERE id = ?', [user.adminId]);
122+
res.json({ ok: true, username: admin?.username, isSuperAdmin: false });
123+
});
124+
125+
// ─── Admin Management (super admin only) ────────────────────────────────────
126+
127+
adminRouter.get('/admins', requireAdmin, async (req, res) => {
128+
if (!(req as any).user.isSuperAdmin) {
129+
return res.status(403).json({ error: 'Only super admin can list admins' });
130+
}
131+
const admins = await db.all(
132+
'SELECT id, username, created_at, last_password_change FROM admins',
133+
);
134+
res.json({ admins });
135+
});
136+
137+
adminRouter.post('/admins/:id/reset-password', requireAdmin, async (req, res) => {
138+
if (!(req as any).user.isSuperAdmin) {
139+
return res.status(403).json({ error: 'Only super admin can reset passwords' });
140+
}
141+
const { newPassword } = req.body as { newPassword?: string };
142+
if (!newPassword) {
143+
return res.status(400).json({ error: 'newPassword required' });
144+
}
145+
const bcrypt = await import('bcrypt');
146+
const hash = await bcrypt.hash(newPassword, 12);
147+
await db.run(
148+
'UPDATE admins SET password_hash = ?, last_password_change = datetime("now") WHERE id = ?',
149+
[hash, req.params.id],
150+
);
151+
res.json({ success: true });
102152
});
103153

104154
// ─── Config ───────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)