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