diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ecc9f35..ad6396b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/index.ts b/backend/src/index.ts index 0951590..c576206 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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' }); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 2ea791b..b4c004a 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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) { diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..d31a21e --- /dev/null +++ b/backend/src/routes/admin.ts @@ -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; diff --git a/backend/src/routes/apikeys.ts b/backend/src/routes/apikeys.ts new file mode 100644 index 0000000..5aa93ff --- /dev/null +++ b/backend/src/routes/apikeys.ts @@ -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; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 90efb29..6413cf0 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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) { diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue index dad780e..abfdd10 100644 --- a/frontend/src/components/NavBar.vue +++ b/frontend/src/components/NavBar.vue @@ -1,9 +1,10 @@