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) {

View file

@ -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>

View 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>

View file

@ -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);

View 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>

View file

@ -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',

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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 },
},
],
});

View file

@ -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;

View file

@ -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 };
});

View file

@ -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;
}