Added ability to control registration by admin user.
This commit is contained in:
parent
2ab11f7a4b
commit
9f4204cc73
18 changed files with 1238 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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
243
backend/src/routes/admin.ts
Normal 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;
|
||||
94
backend/src/routes/apikeys.ts
Normal file
94
backend/src/routes/apikeys.ts
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue