Added ability to control registration by admin user.

This commit is contained in:
Brian McGonagill 2026-03-26 08:13:42 -05:00
parent 2ab11f7a4b
commit 9f4204cc73
18 changed files with 1238 additions and 28 deletions

View file

@ -15,6 +15,8 @@ model User {
screenName String?
avatarUrl String?
unitPreference UnitPreference @default(METRIC)
isAdmin Boolean @default(false)
isApiEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
games Game[] @relation("GameMaster")
@ -22,6 +24,7 @@ model User {
captainOf Team? @relation("TeamCaptain")
chatMessages ChatMessage[]
locationHistory LocationHistory[]
apiKeys ApiKey[]
}
enum UnitPreference {
@ -158,6 +161,32 @@ model LocationHistory {
recordedAt DateTime @default(now())
}
model SystemSettings {
id String @id @default("default")
registrationEnabled Boolean @default(true)
inviteCode String? @unique
updatedAt DateTime @updatedAt
}
model BannedEmail {
id String @id @default(uuid())
email String @unique
reason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ApiKey {
id String @id @default(uuid())
key String @unique
name String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime?
lastUsed DateTime?
createdAt DateTime @default(now())
}
enum Visibility {
PUBLIC
PRIVATE

View file

@ -9,6 +9,8 @@ import teamRoutes from './routes/teams';
import routeRoutes from './routes/routes';
import userRoutes from './routes/users';
import uploadRoutes from './routes/upload';
import adminRoutes from './routes/admin';
import apiKeyRoutes from './routes/apikeys';
import setupSocket from './socket/index';
const app = express();
@ -32,6 +34,8 @@ app.use('/api/teams', teamRoutes);
app.use('/api/routes', routeRoutes);
app.use('/api/users', userRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api', apiKeyRoutes);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });

View file

@ -9,6 +9,7 @@ export interface AuthRequest extends Request {
id: string;
email: string;
name: string;
isAdmin?: boolean;
};
}
@ -24,7 +25,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true }
select: { id: true, email: true, name: true, isAdmin: true }
});
if (!user) {

243
backend/src/routes/admin.ts Normal file
View file

@ -0,0 +1,243 @@
import { Router, Response } from 'express';
import { prisma } from '../index';
import { authenticate, AuthRequest } from '../middleware/auth';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
const router = Router();
router.use(authenticate);
router.get('/settings', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const settings = await prisma.systemSettings.findUnique({
where: { id: 'default' }
});
if (!settings) {
const newSettings = await prisma.systemSettings.create({
data: { id: 'default', registrationEnabled: true }
});
return res.json(newSettings);
}
res.json(settings);
} catch (error) {
console.error('Get settings error:', error);
res.status(500).json({ error: 'Failed to get settings' });
}
});
router.put('/settings', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { registrationEnabled } = req.body;
const settings = await prisma.systemSettings.upsert({
where: { id: 'default' },
update: { registrationEnabled },
create: { id: 'default', registrationEnabled: registrationEnabled ?? true }
});
res.json(settings);
} catch (error) {
console.error('Update settings error:', error);
res.status(500).json({ error: 'Failed to update settings' });
}
});
router.post('/settings/invite-code', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const inviteCode = uuidv4().slice(0, 12);
const settings = await prisma.systemSettings.upsert({
where: { id: 'default' },
update: { inviteCode },
create: { id: 'default', registrationEnabled: true, inviteCode }
});
res.json({ inviteCode: settings.inviteCode });
} catch (error) {
console.error('Generate invite code error:', error);
res.status(500).json({ error: 'Failed to generate invite code' });
}
});
router.delete('/settings/invite-code', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
await prisma.systemSettings.update({
where: { id: 'default' },
data: { inviteCode: null }
});
res.json({ message: 'Invite code removed' });
} catch (error) {
console.error('Remove invite code error:', error);
res.status(500).json({ error: 'Failed to remove invite code' });
}
});
router.get('/users', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
screenName: true,
isAdmin: true,
isApiEnabled: true,
createdAt: true,
_count: {
select: { games: true, teams: true }
}
},
orderBy: { createdAt: 'desc' }
});
res.json(users);
} catch (error) {
console.error('List users error:', error);
res.status(500).json({ error: 'Failed to list users' });
}
});
router.put('/users/:userId/admin', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { userId } = req.params;
const { isAdmin } = req.body;
const user = await prisma.user.update({
where: { id: userId },
data: { isAdmin },
select: {
id: true,
email: true,
name: true,
isAdmin: true
}
});
res.json(user);
} catch (error) {
console.error('Update admin status error:', error);
res.status(500).json({ error: 'Failed to update user' });
}
});
router.put('/users/:userId/api-access', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { userId } = req.params;
const { isApiEnabled } = req.body;
const user = await prisma.user.update({
where: { id: userId },
data: { isApiEnabled },
select: {
id: true,
email: true,
name: true,
isApiEnabled: true
}
});
res.json(user);
} catch (error) {
console.error('Update API access error:', error);
res.status(500).json({ error: 'Failed to update user' });
}
});
router.get('/banned-emails', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const bannedEmails = await prisma.bannedEmail.findMany({
orderBy: { createdAt: 'desc' }
});
res.json(bannedEmails);
} catch (error) {
console.error('List banned emails error:', error);
res.status(500).json({ error: 'Failed to list banned emails' });
}
});
router.post('/banned-emails', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { email, reason } = req.body;
if (!email) {
return res.status(400).json({ error: 'Email is required' });
}
const bannedEmail = await prisma.bannedEmail.create({
data: {
email: email.toLowerCase(),
reason
}
});
res.json(bannedEmail);
} catch (error: any) {
if (error.code === 'P2002') {
return res.status(400).json({ error: 'Email already banned' });
}
console.error('Ban email error:', error);
res.status(500).json({ error: 'Failed to ban email' });
}
});
router.delete('/banned-emails/:id', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
await prisma.bannedEmail.delete({
where: { id }
});
res.json({ message: 'Email unbanned' });
} catch (error) {
console.error('Unban email error:', error);
res.status(500).json({ error: 'Failed to unban email' });
}
});
export default router;

View file

@ -0,0 +1,94 @@
import { Router, Response } from 'express';
import { prisma } from '../index';
import { authenticate, AuthRequest } from '../middleware/auth';
import crypto from 'crypto';
const router = Router();
router.use(authenticate);
router.get('/me/api-keys', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isApiEnabled) {
return res.status(403).json({ error: 'API access is not enabled for your account' });
}
const apiKeys = await prisma.apiKey.findMany({
where: { userId: req.user.id },
select: {
id: true,
name: true,
expiresAt: true,
lastUsed: true,
createdAt: true
}
});
res.json(apiKeys);
} catch (error) {
console.error('Get API keys error:', error);
res.status(500).json({ error: 'Failed to get API keys' });
}
});
router.post('/me/api-keys', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isApiEnabled) {
return res.status(403).json({ error: 'API access is not enabled for your account' });
}
const { name, expiresInDays } = req.body;
if (!name) {
return res.status(400).json({ error: 'Key name is required' });
}
const key = crypto.randomBytes(32).toString('hex');
const keyHash = crypto.createHash('sha256').update(key).digest('hex');
const expiresAt = expiresInDays
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
: null;
const apiKey = await prisma.apiKey.create({
data: {
key: keyHash,
name,
userId: req.user.id,
expiresAt
}
});
res.json({
id: apiKey.id,
name: apiKey.name,
key,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt
});
} catch (error) {
console.error('Create API key error:', error);
res.status(500).json({ error: 'Failed to create API key' });
}
});
router.delete('/me/api-keys/:id', async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.isApiEnabled) {
return res.status(403).json({ error: 'API access is not enabled for your account' });
}
const { id } = req.params;
await prisma.apiKey.delete({
where: { id, userId: req.user.id }
});
res.json({ message: 'API key revoked' });
} catch (error) {
console.error('Delete API key error:', error);
res.status(500).json({ error: 'Failed to delete API key' });
}
});
export default router;

View file

@ -8,27 +8,54 @@ const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
router.post('/register', async (req: Request, res: Response) => {
try {
const { email, password, name } = req.body;
const { email, password, name, inviteCode } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'Email, password, and name are required' });
}
const settings = await prisma.systemSettings.findUnique({
where: { id: 'default' }
});
const isBanned = await prisma.bannedEmail.findUnique({
where: { email: email.toLowerCase() }
});
if (isBanned) {
return res.status(403).json({ error: 'This email is not allowed to register' });
}
if (settings && !settings.registrationEnabled) {
if (!inviteCode || settings.inviteCode !== inviteCode) {
return res.status(403).json({ error: 'Registration is currently closed. An invite code may be required.' });
}
}
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const passwordHash = await bcrypt.hash(password, 10);
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const user = await prisma.user.create({
data: { email, passwordHash, name }
data: {
email,
passwordHash,
name,
isAdmin: isFirstUser
}
});
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user.id, email: user.email, name: user.name }
user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin }
});
} catch (error) {
console.error('Register error:', error);
@ -58,7 +85,7 @@ router.post('/login', async (req: Request, res: Response) => {
res.json({
token,
user: { id: user.id, email: user.email, name: user.name }
user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin }
});
} catch (error) {
console.error('Login error:', error);
@ -66,6 +93,20 @@ router.post('/login', async (req: Request, res: Response) => {
}
});
router.get('/registration-status', async (req: Request, res: Response) => {
try {
const settings = await prisma.systemSettings.findUnique({
where: { id: 'default' }
});
res.json({
enabled: !settings || settings.registrationEnabled
});
} catch (error) {
res.json({ enabled: true });
}
});
router.get('/me', async (req: Request, res: Response) => {
try {
const authHeader = req.headers.authorization;
@ -78,7 +119,7 @@ router.get('/me', async (req: Request, res: Response) => {
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true, createdAt: true }
select: { id: true, email: true, name: true, isAdmin: true, isApiEnabled: true, createdAt: true }
});
if (!user) {