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) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const isOpen = ref(false);
|
||||
|
||||
function toggleNav() {
|
||||
|
|
@ -13,6 +14,12 @@ function toggleNav() {
|
|||
function closeNav() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout();
|
||||
closeNav();
|
||||
router.push('/logout');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -32,13 +39,14 @@ function closeNav() {
|
|||
|
||||
<ul v-if="authStore.isLoggedIn">
|
||||
<li><RouterLink to="/dashboard" @click="closeNav">Dashboard</RouterLink></li>
|
||||
<li v-if="authStore.isAdmin"><RouterLink to="/admin" @click="closeNav">Admin</RouterLink></li>
|
||||
<li><RouterLink to="/settings" @click="closeNav">Settings</RouterLink></li>
|
||||
</ul>
|
||||
|
||||
<ul class="nav-actions">
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<li><span class="user-name">{{ authStore.user?.name }}</span></li>
|
||||
<li><button @click="authStore.logout(); closeNav()" class="secondary">Logout</button></li>
|
||||
<li><button @click="handleLogout" class="secondary">Logout</button></li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li><RouterLink to="/login" @click="closeNav">Login</RouterLink></li>
|
||||
|
|
@ -62,8 +70,9 @@ function closeNav() {
|
|||
<li><RouterLink to="/" @click="closeNav">Home</RouterLink></li>
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<li><RouterLink to="/dashboard" @click="closeNav">Dashboard</RouterLink></li>
|
||||
<li v-if="authStore.isAdmin"><RouterLink to="/admin" @click="closeNav">Admin</RouterLink></li>
|
||||
<li><RouterLink to="/settings" @click="closeNav">Settings</RouterLink></li>
|
||||
<li><button @click="authStore.logout(); closeNav()" class="secondary">Logout</button></li>
|
||||
<li><button @click="handleLogout" class="secondary">Logout</button></li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li><RouterLink to="/login" @click="closeNav">Login</RouterLink></li>
|
||||
|
|
|
|||
391
frontend/src/pages/AdminPage.vue
Normal file
391
frontend/src/pages/AdminPage.vue
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { adminService } from '../services/api';
|
||||
import { alert, confirm } from '../composables/useModal';
|
||||
import type { SystemSettings, BannedEmail, AdminUser } from '../types';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
|
||||
const settings = ref<SystemSettings | null>(null);
|
||||
const users = ref<AdminUser[]>([]);
|
||||
const bannedEmails = ref<BannedEmail[]>([]);
|
||||
|
||||
const newBanEmail = ref('');
|
||||
const newBanReason = ref('');
|
||||
const activeTab = ref<'settings' | 'users' | 'banned'>('settings');
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const response = await adminService.getSettings();
|
||||
settings.value = response.data;
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await adminService.getUsers();
|
||||
users.value = response.data;
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBannedEmails() {
|
||||
try {
|
||||
const response = await adminService.getBannedEmails();
|
||||
bannedEmails.value = response.data;
|
||||
} catch (err) {
|
||||
console.error('Failed to load banned emails:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRegistration() {
|
||||
if (!settings.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await adminService.updateSettings({
|
||||
registrationEnabled: !settings.value.registrationEnabled
|
||||
});
|
||||
settings.value.registrationEnabled = !settings.value.registrationEnabled;
|
||||
} catch (err) {
|
||||
await alert('Failed to update settings');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateInviteCode() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const response = await adminService.generateInviteCode();
|
||||
settings.value!.inviteCode = response.data.inviteCode;
|
||||
await alert(`Invite code generated: ${response.data.inviteCode}\n\nShare this code with users who need an invite to register.`);
|
||||
} catch (err) {
|
||||
await alert('Failed to generate invite code');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeInviteCode() {
|
||||
if (!await confirm('Are you sure you want to remove the invite code?')) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await adminService.removeInviteCode();
|
||||
settings.value!.inviteCode = undefined;
|
||||
} catch (err) {
|
||||
await alert('Failed to remove invite code');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUserAdmin(userId: string, currentStatus: boolean) {
|
||||
try {
|
||||
await adminService.setUserAdmin(userId, !currentStatus);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
await alert('Failed to update user');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUserApiAccess(userId: string, currentStatus: boolean) {
|
||||
try {
|
||||
await adminService.setUserApiAccess(userId, !currentStatus);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
await alert('Failed to update user');
|
||||
}
|
||||
}
|
||||
|
||||
async function banEmail() {
|
||||
if (!newBanEmail.value.trim()) {
|
||||
await alert('Please enter an email address');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adminService.banEmail({
|
||||
email: newBanEmail.value.trim(),
|
||||
reason: newBanReason.value.trim() || undefined
|
||||
});
|
||||
newBanEmail.value = '';
|
||||
newBanReason.value = '';
|
||||
await loadBannedEmails();
|
||||
await alert('Email banned successfully');
|
||||
} catch (err: any) {
|
||||
await alert(err.response?.data?.error || 'Failed to ban email');
|
||||
}
|
||||
}
|
||||
|
||||
async function unbanEmail(id: string) {
|
||||
if (!await confirm('Are you sure you want to unban this email?')) return;
|
||||
|
||||
try {
|
||||
await adminService.unbanEmail(id);
|
||||
await loadBannedEmails();
|
||||
} catch (err) {
|
||||
await alert('Failed to unban email');
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInviteCode() {
|
||||
if (settings.value?.inviteCode) {
|
||||
await navigator.clipboard.writeText(settings.value.inviteCode);
|
||||
await alert('Invite code copied to clipboard!');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.isAdmin) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
await Promise.all([loadSettings(), loadUsers(), loadBannedEmails()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container">
|
||||
<h1>Admin Settings</h1>
|
||||
|
||||
<article v-if="loading">Loading...</article>
|
||||
|
||||
<article v-else-if="!authStore.isAdmin">
|
||||
<h2>Access Denied</h2>
|
||||
<p>You do not have permission to access this page.</p>
|
||||
</article>
|
||||
|
||||
<template v-else>
|
||||
<nav class="grid" style="grid-template-columns: auto 1fr 1fr; gap: 1rem;">
|
||||
<button
|
||||
@click="activeTab = 'settings'"
|
||||
:class="{ '': activeTab !== 'settings', 'primary': activeTab === 'settings' }"
|
||||
>
|
||||
System Settings
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'users'"
|
||||
:class="{ '': activeTab !== 'users', 'primary': activeTab === 'users' }"
|
||||
>
|
||||
Users ({{ users.length }})
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'banned'"
|
||||
:class="{ '': activeTab !== 'banned', 'primary': activeTab === 'banned' }"
|
||||
>
|
||||
Banned Emails ({{ bannedEmails.length }})
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<article v-if="activeTab === 'settings'">
|
||||
<h2>System Settings</h2>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Registration</h3>
|
||||
<p>{{ settings?.registrationEnabled ? 'New users can register' : 'Registration is closed' }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleRegistration"
|
||||
:disabled="saving"
|
||||
:class="settings?.registrationEnabled ? 'secondary' : 'primary'"
|
||||
>
|
||||
{{ settings?.registrationEnabled ? 'Disable Registration' : 'Enable Registration' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<h3>Invite Code</h3>
|
||||
<p v-if="settings?.inviteCode">
|
||||
Current code: <code>{{ settings.inviteCode }}</code>
|
||||
</p>
|
||||
<p v-else>No invite code set</p>
|
||||
<p class="hint">When registration is disabled, users must have an invite code to register.</p>
|
||||
</div>
|
||||
<div class="setting-actions">
|
||||
<button v-if="settings?.inviteCode" @click="copyInviteCode" class="secondary">Copy</button>
|
||||
<button v-if="settings?.inviteCode" @click="removeInviteCode" class="contrast" :disabled="saving">Remove</button>
|
||||
<button v-else @click="generateInviteCode" class="primary" :disabled="saving">Generate Code</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="activeTab === 'users'">
|
||||
<h2>Users</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Games</th>
|
||||
<th>Teams</th>
|
||||
<th>Admin</th>
|
||||
<th>API Access</th>
|
||||
<th>Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.name }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user._count.games }}</td>
|
||||
<td>{{ user._count.teams }}</td>
|
||||
<td>
|
||||
<button
|
||||
@click="toggleUserAdmin(user.id, user.isAdmin)"
|
||||
:class="user.isAdmin ? 'primary' : 'secondary'"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
|
||||
>
|
||||
{{ user.isAdmin ? 'Yes' : 'No' }}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@click="toggleUserApiAccess(user.id, user.isApiEnabled)"
|
||||
:class="user.isApiEnabled ? 'primary' : 'secondary'"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
|
||||
>
|
||||
{{ user.isApiEnabled ? 'Enabled' : 'Disabled' }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ new Date(user.createdAt).toLocaleDateString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article v-if="activeTab === 'banned'">
|
||||
<h2>Banned Emails</h2>
|
||||
|
||||
<form @submit.prevent="banEmail" class="ban-form">
|
||||
<label>
|
||||
Email Address
|
||||
<input v-model="newBanEmail" type="email" placeholder="email@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
Reason (optional)
|
||||
<input v-model="newBanReason" type="text" placeholder="Reason for ban" />
|
||||
</label>
|
||||
<button type="submit" :disabled="!newBanEmail.trim()">Ban Email</button>
|
||||
</form>
|
||||
|
||||
<hr v-if="bannedEmails.length" />
|
||||
|
||||
<div v-if="bannedEmails.length" class="banned-list">
|
||||
<div v-for="ban in bannedEmails" :key="ban.id" class="banned-item">
|
||||
<div class="banned-info">
|
||||
<strong>{{ ban.email }}</strong>
|
||||
<small v-if="ban.reason">{{ ban.reason }}</small>
|
||||
<small class="muted">Banned {{ new Date(ban.createdAt).toLocaleDateString() }}</small>
|
||||
</div>
|
||||
<button @click="unbanEmail(ban.id)" class="secondary outline">Unban</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted">No banned emails</p>
|
||||
</article>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-info h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.setting-info p {
|
||||
margin: 0;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.setting-info .hint {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.setting-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ban-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.ban-form label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.banned-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.banned-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
.banned-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.banned-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.banned-info .muted {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--pico-muted-border-color);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table th {
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -99,7 +99,9 @@ function initMap() {
|
|||
}
|
||||
|
||||
function connectSocket() {
|
||||
socket = io('http://localhost:3001');
|
||||
socket = io(window.location.origin, {
|
||||
path: '/socket.io'
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket?.emit('join-game', gameId.value);
|
||||
|
|
|
|||
65
frontend/src/pages/LogoutPage.vue
Normal file
65
frontend/src/pages/LogoutPage.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 5000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container">
|
||||
<article class="logout-message">
|
||||
<div class="icon">👋</div>
|
||||
<h1>You've Been Logged Out</h1>
|
||||
<p>Thanks for playing! We hope you had a great time on the trail.</p>
|
||||
<p class="come-back">Come back soon for more adventures and exciting hunts!</p>
|
||||
|
||||
<div class="actions">
|
||||
<RouterLink to="/" role="button">Back to Home</RouterLink>
|
||||
</div>
|
||||
|
||||
<p class="redirect-note"><small>Redirecting to home page in a few seconds...</small></p>
|
||||
</article>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logout-message {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
margin: 3rem auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--pico-primary-focus);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.come-back {
|
||||
font-size: 1.1rem;
|
||||
color: var(--pico-color);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.redirect-note {
|
||||
margin-top: 2rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -91,7 +91,9 @@ function initMap() {
|
|||
}
|
||||
|
||||
function connectSocket() {
|
||||
socket = io('http://localhost:3001');
|
||||
socket = io(window.location.origin, {
|
||||
path: '/socket.io'
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket?.emit('join-game', gameId.value);
|
||||
|
|
@ -176,7 +178,7 @@ async function submitPhoto() {
|
|||
uploading.value = true;
|
||||
try {
|
||||
const uploadRes = await uploadService.upload(photoFile.value);
|
||||
await fetch(`http://localhost:3001/api/routes/${currentRoute.value?.id}/legs/${currentLeg.value.id}/photo`, {
|
||||
await fetch(`/api/routes/${currentRoute.value?.id}/legs/${currentLeg.value.id}/photo`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { authService } from '../services/api';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
|
@ -9,22 +10,51 @@ const authStore = useAuthStore();
|
|||
const name = ref('');
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const inviteCode = ref('');
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
const registrationClosed = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await authService.checkRegistration();
|
||||
registrationClosed.value = !response.data.enabled;
|
||||
} catch {
|
||||
registrationClosed.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = '';
|
||||
|
||||
if (!name.value || !email.value || !password.value) {
|
||||
error.value = 'Please fill in all fields';
|
||||
error.value = 'Please fill in all required fields';
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await authStore.register(email.value, password.value, name.value);
|
||||
if (registrationClosed.value && !inviteCode.value) {
|
||||
error.value = 'An invite code is required to register';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
if (success) {
|
||||
try {
|
||||
const response = await authService.register({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
name: name.value,
|
||||
inviteCode: inviteCode.value || undefined
|
||||
});
|
||||
|
||||
authStore.token = response.data.token;
|
||||
authStore.user = response.data.user;
|
||||
localStorage.setItem('token', response.data.token);
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
error.value = 'Registration failed. Email may already be in use.';
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Registration failed. Please try again.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -34,6 +64,11 @@ async function handleSubmit() {
|
|||
<article>
|
||||
<h1>Register</h1>
|
||||
|
||||
<article v-if="registrationClosed" class="registration-closed">
|
||||
<h2>Registration is Closed</h2>
|
||||
<p>Public registration is currently disabled. If you have an invite code, you can still register below.</p>
|
||||
</article>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
|
|
@ -62,8 +97,20 @@ async function handleSubmit() {
|
|||
required
|
||||
/>
|
||||
|
||||
<button type="submit" :disabled="authStore.loading">
|
||||
{{ authStore.loading ? 'Creating account...' : 'Register' }}
|
||||
<label for="inviteCode">
|
||||
Invite Code
|
||||
<small v-if="registrationClosed">(Required)</small>
|
||||
<small v-else>(Optional)</small>
|
||||
</label>
|
||||
<input
|
||||
id="inviteCode"
|
||||
v-model="inviteCode"
|
||||
type="text"
|
||||
placeholder="Enter invite code if you have one"
|
||||
/>
|
||||
|
||||
<button type="submit" :disabled="loading">
|
||||
{{ loading ? 'Creating account...' : 'Register' }}
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
|
@ -78,4 +125,19 @@ async function handleSubmit() {
|
|||
.error {
|
||||
color: var(--pico-del-color);
|
||||
}
|
||||
|
||||
.registration-closed {
|
||||
background: var(--pico-mark-background-color);
|
||||
border-color: var(--pico-mark-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.registration-closed h2 {
|
||||
color: var(--pico-mark-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.registration-closed p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { userService, uploadService } from '../services/api';
|
||||
import type { User, UserGameHistory, LocationHistory } from '../types';
|
||||
import { userService, uploadService, apiKeyService } from '../services/api';
|
||||
import type { User, UserGameHistory, LocationHistory, ApiKey } from '../types';
|
||||
import { alert, confirm } from '../composables/useModal';
|
||||
import { formatDistance } from '../utils/units';
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ const authStore = useAuthStore();
|
|||
const user = ref<User | null>(null);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const activeTab = ref<'profile' | 'locations' | 'games'>('profile');
|
||||
const activeTab = ref<'profile' | 'locations' | 'games' | 'apikeys'>('profile');
|
||||
|
||||
const name = ref('');
|
||||
const screenName = ref('');
|
||||
|
|
@ -28,6 +28,11 @@ const unitPreference = ref<'METRIC' | 'IMPERIAL'>('METRIC');
|
|||
|
||||
const displayUnitPreference = computed(() => unitPreference.value);
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([]);
|
||||
const newKeyName = ref('');
|
||||
const newKeyExpiry = ref<number | undefined>(undefined);
|
||||
const showNewKey = ref<string | null>(null);
|
||||
|
||||
async function loadProfile() {
|
||||
loading.value = true;
|
||||
try {
|
||||
|
|
@ -63,6 +68,54 @@ async function loadGamesHistory() {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadApiKeys() {
|
||||
try {
|
||||
const response = await apiKeyService.getKeys();
|
||||
apiKeys.value = response.data;
|
||||
} catch (err) {
|
||||
console.error('Failed to load API keys:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
if (!newKeyName.value.trim()) {
|
||||
await alert('Please enter a name for this API key');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiKeyService.createKey({
|
||||
name: newKeyName.value.trim(),
|
||||
expiresInDays: newKeyExpiry.value
|
||||
});
|
||||
showNewKey.value = response.data.key || null;
|
||||
newKeyName.value = '';
|
||||
newKeyExpiry.value = undefined;
|
||||
await loadApiKeys();
|
||||
} catch (err: any) {
|
||||
await alert(err.response?.data?.error || 'Failed to create API key');
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeApiKey(id: string) {
|
||||
if (!await confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await apiKeyService.revokeKey(id);
|
||||
await loadApiKeys();
|
||||
await alert('API key revoked');
|
||||
} catch (err) {
|
||||
await alert('Failed to revoke API key');
|
||||
}
|
||||
}
|
||||
|
||||
async function copyApiKey() {
|
||||
if (showNewKey.value) {
|
||||
await navigator.clipboard.writeText(showNewKey.value);
|
||||
await alert('API key copied to clipboard! Save it now - you won\'t be able to see it again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAvatarSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files?.[0]) {
|
||||
|
|
@ -127,6 +180,9 @@ onMounted(() => {
|
|||
loadProfile();
|
||||
loadLocationHistory();
|
||||
loadGamesHistory();
|
||||
if (user.value?.isApiEnabled) {
|
||||
loadApiKeys();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -153,6 +209,13 @@ onMounted(() => {
|
|||
>
|
||||
My Games
|
||||
</button>
|
||||
<button
|
||||
v-if="user?.isApiEnabled"
|
||||
@click="activeTab = 'apikeys'; loadApiKeys()"
|
||||
:class="{ '': activeTab !== 'apikeys', 'primary': activeTab === 'apikeys' }"
|
||||
>
|
||||
API Keys
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<article v-if="loading">Loading...</article>
|
||||
|
|
@ -317,6 +380,59 @@ onMounted(() => {
|
|||
</details>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="activeTab === 'apikeys'">
|
||||
<h2>API Keys</h2>
|
||||
|
||||
<div v-if="!user?.isApiEnabled" class="api-not-enabled">
|
||||
<p>API access is not enabled for your account. Contact an administrator to request access.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<article v-if="showNewKey" class="new-key-banner">
|
||||
<h3>New API Key Created!</h3>
|
||||
<p><strong>Copy this key now - you won't be able to see it again!</strong></p>
|
||||
<code class="key-display">{{ showNewKey }}</code>
|
||||
<div class="new-key-actions">
|
||||
<button @click="copyApiKey" class="secondary">Copy to Clipboard</button>
|
||||
<button @click="showNewKey = null" class="primary">Done</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<form @submit.prevent="createApiKey" class="create-key-form">
|
||||
<label>
|
||||
Key Name
|
||||
<input v-model="newKeyName" type="text" placeholder="e.g., Production API" required />
|
||||
</label>
|
||||
<label>
|
||||
Expires In (days, optional)
|
||||
<input v-model.number="newKeyExpiry" type="number" min="1" placeholder="Leave empty for no expiry" />
|
||||
</label>
|
||||
<button type="submit">Generate New Key</button>
|
||||
</form>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3>Your API Keys</h3>
|
||||
<div v-if="apiKeys.length === 0" class="no-keys">
|
||||
<p>No API keys yet. Generate one above to get started.</p>
|
||||
</div>
|
||||
<div v-else class="keys-list">
|
||||
<div v-for="key in apiKeys" :key="key.id" class="key-item">
|
||||
<div class="key-info">
|
||||
<strong>{{ key.name }}</strong>
|
||||
<small>
|
||||
Created {{ new Date(key.createdAt).toLocaleDateString() }}
|
||||
<span v-if="key.expiresAt"> · Expires {{ new Date(key.expiresAt).toLocaleDateString() }}</span>
|
||||
<span v-else> · Never expires</span>
|
||||
<span v-if="key.lastUsed"> · Last used {{ new Date(key.lastUsed).toLocaleDateString() }}</span>
|
||||
</small>
|
||||
</div>
|
||||
<button @click="revokeApiKey(key.id)" class="contrast outline">Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
|
@ -490,4 +606,77 @@ onMounted(() => {
|
|||
color: var(--pico-muted-color);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.api-not-enabled {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.new-key-banner {
|
||||
background: var(--pico-ins-background-color);
|
||||
border-color: var(--pico-ins-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.new-key-banner h3 {
|
||||
color: var(--pico-ins-color);
|
||||
}
|
||||
|
||||
.key-display {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
background: var(--pico-background-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
word-break: break-all;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.new-key-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.create-key-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.create-key-form label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.keys-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.key-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
}
|
||||
|
||||
.key-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.key-info small {
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.no-keys {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -102,7 +102,9 @@ function initMap() {
|
|||
}
|
||||
|
||||
function connectSocket() {
|
||||
socket = io('http://localhost:3001');
|
||||
socket = io(window.location.origin, {
|
||||
path: '/socket.io'
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket?.emit('join-game', gameId.value);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ const router = createRouter({
|
|||
name: 'register',
|
||||
component: () => import('../pages/RegisterPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/logout',
|
||||
name: 'logout',
|
||||
component: () => import('../pages/LogoutPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
|
|
@ -73,6 +78,12 @@ const router = createRouter({
|
|||
component: () => import('../pages/SettingsPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: () => import('../pages/AdminPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import axios from 'axios';
|
||||
import type { Game, Route, RouteLeg, Team, User, AuthResponse, LocationHistory, UserGameHistory } from '../types';
|
||||
import type { Game, Route, RouteLeg, Team, User, AuthResponse, LocationHistory, UserGameHistory, SystemSettings, BannedEmail, AdminUser, ApiKey } from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:3001/api',
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
|
|
@ -14,11 +14,12 @@ api.interceptors.request.use((config) => {
|
|||
});
|
||||
|
||||
export const authService = {
|
||||
register: (data: { email: string; password: string; name: string }) =>
|
||||
register: (data: { email: string; password: string; name: string; inviteCode?: string }) =>
|
||||
api.post<AuthResponse>('/auth/register', data),
|
||||
login: (data: { email: string; password: string }) =>
|
||||
api.post<AuthResponse>('/auth/login', data),
|
||||
me: () => api.get<User>('/auth/me'),
|
||||
checkRegistration: () => api.get<{ enabled: boolean }>('/auth/registration-status'),
|
||||
};
|
||||
|
||||
export const gameService = {
|
||||
|
|
@ -89,4 +90,28 @@ export const userService = {
|
|||
deleteAccount: () => api.delete('/users/me/account'),
|
||||
};
|
||||
|
||||
export const adminService = {
|
||||
getSettings: () => api.get<SystemSettings>('/admin/settings'),
|
||||
updateSettings: (data: { registrationEnabled: boolean }) =>
|
||||
api.put<SystemSettings>('/admin/settings', data),
|
||||
generateInviteCode: () => api.post<{ inviteCode: string }>('/admin/settings/invite-code', {}),
|
||||
removeInviteCode: () => api.delete('/admin/settings/invite-code'),
|
||||
getUsers: () => api.get<AdminUser[]>('/admin/users'),
|
||||
setUserAdmin: (userId: string, isAdmin: boolean) =>
|
||||
api.put<AdminUser>(`/admin/users/${userId}/admin`, { isAdmin }),
|
||||
setUserApiAccess: (userId: string, isApiEnabled: boolean) =>
|
||||
api.put<AdminUser>(`/admin/users/${userId}/api-access`, { isApiEnabled }),
|
||||
getBannedEmails: () => api.get<BannedEmail[]>('/admin/banned-emails'),
|
||||
banEmail: (data: { email: string; reason?: string }) =>
|
||||
api.post<BannedEmail>('/admin/banned-emails', data),
|
||||
unbanEmail: (id: string) => api.delete(`/admin/banned-emails/${id}`),
|
||||
};
|
||||
|
||||
export const apiKeyService = {
|
||||
getKeys: () => api.get<ApiKey[]>('/me/api-keys'),
|
||||
createKey: (data: { name: string; expiresInDays?: number }) =>
|
||||
api.post<ApiKey>('/me/api-keys', data),
|
||||
revokeKey: (id: string) => api.delete(`/me/api-keys/${id}`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
const loading = ref(false);
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value && !!user.value);
|
||||
const isAdmin = computed(() => user.value?.isAdmin === true);
|
||||
|
||||
async function init() {
|
||||
if (!token.value) return;
|
||||
|
|
@ -58,5 +59,5 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
return { user, token, loading, isLoggedIn, init, login, register, logout };
|
||||
return { user, token, loading, isLoggedIn, isAdmin, init, login, register, logout };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export interface User {
|
|||
screenName?: string;
|
||||
avatarUrl?: string;
|
||||
unitPreference?: 'METRIC' | 'IMPERIAL';
|
||||
isAdmin?: boolean;
|
||||
isApiEnabled?: boolean;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
|
|
@ -150,3 +152,40 @@ export interface AuthResponse {
|
|||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
id: string;
|
||||
registrationEnabled: boolean;
|
||||
inviteCode?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BannedEmail {
|
||||
id: string;
|
||||
email: string;
|
||||
reason?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
screenName?: string;
|
||||
isAdmin: boolean;
|
||||
isApiEnabled: boolean;
|
||||
createdAt: string;
|
||||
_count: {
|
||||
games: number;
|
||||
teams: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key?: string;
|
||||
expiresAt?: string;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue