feat(server): allow unauth'd requests to stats routes
vi did:web:vt3e.cat
Sat, 09 May 2026 05:04:49 +0100
6 files changed,
63 insertions(+),
52 deletions(-)
M
frontend/src/css/index.css
→
frontend/src/css/index.css
@@ -115,3 +115,33 @@ font-size: 3.5rem;
font-weight: 950; } } + +.login-prompt { + display: inline-flex; + flex-direction: row-reverse; + align-items: center; + justify-content: flex-end; + gap: 1rem; + padding: 0.5rem; + padding-right: 1.5rem; + border-radius: 5rem; + margin-bottom: 0.5rem; + + background-color: hsla(var(--surface0) / 1); +} + +a.login-button { + display: inline-block; + padding: 0.75rem 1.5rem; + background-color: hsla(var(--accent) / 0.9); + color: hsl(var(--on-accent)); + text-decoration: none; + border-radius: 5rem; + &:hover { + background-color: hsla(var(--accent) / 1); + } + &:active { + background-color: hsla(var(--accent) / 0.75); + scale: 0.975; + } +}
M
frontend/src/router/index.ts
→
frontend/src/router/index.ts
@@ -14,11 +14,17 @@ {
path: "/tickets", name: "Tickets", component: () => import("../views/TicketsView.vue"), + meta: { + requiresAuth: true, + }, }, { path: "/tickets/:id", name: "Ticket Details", component: () => import("../views/TicketDetailView.vue"), + meta: { + requiresAuth: true, + }, }, { path: "/login",@@ -33,7 +39,7 @@ const authStore = useAuthStore();
await authStore.waitForAuth(); const isAuthenticated = authStore.isAuthenticated; - if (to.name !== "Login" && !isAuthenticated) { + if (to.meta.requiresAuth && !isAuthenticated) { next({ name: "Login" }); } else { next();
M
frontend/src/stores/auth.ts
→
frontend/src/stores/auth.ts
@@ -4,6 +4,7 @@ import type { User } from "@/types";
export const useAuthStore = defineStore("auth", () => { const isAuthenticated = ref(false); + const isLoading = ref(true); const user = ref<User | null>(null); async function getUser() {@@ -13,10 +14,12 @@ const data = await response.json();
user.value = data.profile; isAuthenticated.value = true; } catch {} + + isLoading.value = false; } async function waitForAuth() { - while (!isAuthenticated.value) { + while (isLoading.value) { await getUser(); } }
M
frontend/src/views/HomeView.vue
→
frontend/src/views/HomeView.vue
@@ -2,6 +2,9 @@ <script setup lang="ts">
import { onMounted, ref } from 'vue' import type { User } from '@/types' import UserChip from '@/components/UserChip.vue' +import { useAuthStore } from '@/stores/auth' + +const authStore = useAuthStore() type StatsOverall = { global: { mean: number; median: number; count: number }@@ -48,9 +51,9 @@ </script>
<template> <div class="stats-header"> - <h1>Ticket Statistics</h1> + <h1>Stats</h1> <div class="filter"> - <label for="daysFilter">Timeframe</label> + <label class="sr-only" for="daysFilter">Timeframe</label> <select id="daysFilter" v-model="daysFilter" @change="fetchStats"> <option value="">All time</option> <option value="1">Past 24 hours</option>@@ -59,6 +62,11 @@ <option value="30">Past 30 days</option>
<option value="90">Past 90 days</option> </select> </div> + </div> + + <div v-if="!authStore.isAuthenticated" class="login-prompt"> + <p>Log in to view your tickets.</p> + <router-link :to="{ name: 'Login' }" class="login-button">Login</router-link> </div> <div v-if="overallStats" class="overall-stats">@@ -139,7 +147,6 @@ flex-wrap: wrap;
justify-content: space-between; align-items: center; gap: 1rem; - margin-bottom: 2rem; .filter { display: flex;@@ -153,26 +160,24 @@ }
select { padding: 0.5rem 2.5rem 0.5rem 1rem; - border-radius: 0.5rem; + border-radius: 5rem; border: 2px solid hsl(var(--surface0)); background-color: hsl(var(--base)); color: hsl(var(--text)); font-size: 1rem; font-family: inherit; cursor: pointer; - outline: none; appearance: none; - transition: border-color 0.2s; + position: relative; + - background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23a6accd%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'); background-repeat: no-repeat; background-position: right 0.75rem top 50%; background-size: 0.65rem auto; - } - select:hover, - select:focus { - border-color: hsla(var(--accent) / 0.5); + &:active { + scale: 0.975; + } } } }@@ -209,7 +214,6 @@ display: grid;
grid-template-columns: 1fr 1fr; gap: 2rem; - /* Prevent grid items from stretching the grid and causing page-level overflow */ > section { min-width: 0; }
M
frontend/src/views/LoginView.vue
→
frontend/src/views/LoginView.vue
@@ -1,20 +1,10 @@
<template> <h1>Login</h1> - <p>You must authenticate with Discord to continue.</p> - <a href="/api/login"> - Login with Discord - </a> + <div class="login-prompt"> + <p>You must login with Discord to continue.</p> + <a href="/api/login" class="login-button"> + Login with Discord + </a> + </div> </template> - -<style scoped> - a { - display: inline-block; - margin: 1rem 0; - padding: 0.75rem 1.5rem; - background-color: hsl(var(--accent)); - color: hsl(var(--on-accent)); - text-decoration: none; - border-radius: 5rem; - } -</style>
M
src/server/index.ts
→
src/server/index.ts
@@ -366,17 +366,6 @@ },
}, "/api/stats/overall": { GET: async (req: BunRequest) => { - const user = await getSessionUser(req); - if (!user) return new Response("Unauthorized", { status: 401 }); - - const modCheck = await db - .select() - .from(moderators) - .where(eq(moderators.user_id, user.id)) - .limit(1); - if (modCheck.length === 0) - return new Response("Forbidden", { status: 403 }); - const url = new URL(req.url); const daysStr = url.searchParams.get("days"); const days = daysStr ? Number.parseInt(daysStr, 10) : null;@@ -442,17 +431,6 @@ },
}, "/api/stats/leaderboard": { GET: async (req: BunRequest) => { - const user = await getSessionUser(req); - if (!user) return new Response("Unauthorized", { status: 401 }); - - const modCheck = await db - .select() - .from(moderators) - .where(eq(moderators.user_id, user.id)) - .limit(1); - if (modCheck.length === 0) - return new Response("Forbidden", { status: 403 }); - const url = new URL(req.url); const daysStr = url.searchParams.get("days"); const days = daysStr ? Number.parseInt(daysStr, 10) : null;