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

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