diff --git a/.changeset/loud-otters-hide.md b/.changeset/loud-otters-hide.md new file mode 100644 index 00000000..d588b31e --- /dev/null +++ b/.changeset/loud-otters-hide.md @@ -0,0 +1,5 @@ +--- +"enspire": minor +--- + +admin page diff --git a/app/components/custom/sidebar.vue b/app/components/custom/sidebar.vue index 72476670..80007d64 100644 --- a/app/components/custom/sidebar.vue +++ b/app/components/custom/sidebar.vue @@ -1,9 +1,12 @@ @@ -171,7 +188,7 @@ const sidebarData = ref({ v-for="item in sidebarData.school" :key="item.name" class="rounded" - :class="{ 'bg-foreground/10': $route.path === item.url }" + :class="{ 'bg-foreground/10': route.path === item.url }" > @@ -206,7 +223,7 @@ const sidebarData = ref({ :key="subItem.title" class="flex items-center" > -
+
{{ subItem.title }} @@ -219,6 +236,24 @@ const sidebarData = ref({ + + 管理 + + + + + + {{ item.name }} + + + + + diff --git a/app/components/ui/progress/Progress.vue b/app/components/ui/progress/Progress.vue new file mode 100644 index 00000000..ea8f6d10 --- /dev/null +++ b/app/components/ui/progress/Progress.vue @@ -0,0 +1,39 @@ + + + diff --git a/app/components/ui/progress/index.ts b/app/components/ui/progress/index.ts new file mode 100644 index 00000000..eace9893 --- /dev/null +++ b/app/components/ui/progress/index.ts @@ -0,0 +1 @@ +export { default as Progress } from './Progress.vue' diff --git a/app/middleware/admin.ts b/app/middleware/admin.ts new file mode 100644 index 00000000..b1297b3b --- /dev/null +++ b/app/middleware/admin.ts @@ -0,0 +1,33 @@ +import { Roles } from '@prisma/client' +import { until } from '@vueuse/core' +import { useClerk, useClerkProvider } from 'vue-clerk' +import { getRole, isAdmin } from '~~/utils/user-roles' + +export default defineNuxtRouteMiddleware(async () => { + // Modified from auth.ts + + const nuxtApp = useNuxtApp() + const clerk = useClerk() + const { isClerkLoaded } = useClerkProvider() + + if (import.meta.client) { + if (nuxtApp.isHydrating && nuxtApp.payload.serverRendered) + return + + await until(isClerkLoaded).toBe(true) + if (clerk.loaded && clerk.user?.id == null) + return navigateTo('/sign-in') + const response = await $fetch('/api/user/check-role') + if (!isAdmin(response)) + return abortNavigation() + } + + if (import.meta.server) { + const id = nuxtApp.ssrContext?.event.context.auth?.userId + if (id == null) + return navigateTo('/sign-in') + const response = await getRole(id) + if (!isAdmin(response)) + return abortNavigation() + } +}) diff --git a/app/pages/admin/manage-files.vue b/app/pages/admin/manage-files.vue new file mode 100644 index 00000000..c1fe1daf --- /dev/null +++ b/app/pages/admin/manage-files.vue @@ -0,0 +1,265 @@ + + + diff --git a/db/migrations/20250110055647_user_roles/migration.sql b/db/migrations/20250110055647_user_roles/migration.sql new file mode 100644 index 00000000..9dd32add --- /dev/null +++ b/db/migrations/20250110055647_user_roles/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "Roles" AS ENUM ('MEMBER', 'ADMIN'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "role" "Roles" NOT NULL DEFAULT 'MEMBER'; diff --git a/db/schema.prisma b/db/schema.prisma index a9f2c7d6..8a0346ef 100644 --- a/db/schema.prisma +++ b/db/schema.prisma @@ -15,6 +15,12 @@ model User { tsimsStudentId Int @unique ClubRating ClubRating[] ReservationRecord ReservationRecord[] + role Roles @default(MEMBER) +} + +enum Roles { + MEMBER + ADMIN } model Club { @@ -65,7 +71,7 @@ model FileUploadRecord { } model FileCollection { - id String @id @default(dbgenerated("gen_random_uuid()")) + id String @id @default(dbgenerated("gen_random_uuid()")) name String status FormStatus fileNaming String diff --git a/package.json b/package.json index 7514dc0a..2f9ac2b2 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "h3-clerk": "^0.5.22", "import-in-the-middle": "^1.12.0", "iron-webcrypto": "^1.2.1", + "jszip": "^3.10.1", "lucide-vue-next": "^0.466.0", "ofetch": "^1.4.1", "radix-vue": "^1.9.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9639e07..7f1413d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: iron-webcrypto: specifier: ^1.2.1 version: 1.2.1 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lucide-vue-next: specifier: ^0.466.0 version: 0.466.0(vue@3.5.13(typescript@5.7.2)) @@ -5505,6 +5508,9 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + kdbush@3.0.0: resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} @@ -5550,6 +5556,9 @@ packages: lie@3.1.1: resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -6204,6 +6213,9 @@ packages: pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -6908,6 +6920,9 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -14807,6 +14822,13 @@ snapshots: jsonpointer@5.0.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + kdbush@3.0.0: {} keyv@4.5.4: @@ -14845,6 +14867,10 @@ snapshots: dependencies: immediate: 3.0.6 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -15876,6 +15902,8 @@ snapshots: pako@0.2.9: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -16700,6 +16728,8 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} shadcn-nuxt@0.10.4(magicast@0.3.5)(rollup@4.29.1): diff --git a/server/api/admin/all-records.get.ts b/server/api/admin/all-records.get.ts new file mode 100644 index 00000000..389b8870 --- /dev/null +++ b/server/api/admin/all-records.get.ts @@ -0,0 +1,38 @@ +import { PrismaClient } from '@prisma/client' +import { getRole, isAdmin } from '~~/utils/user-roles' + +const prisma = new PrismaClient() + +export default eventHandler(async (event) => { + const { auth } = event.context + + if ((auth?.userId) == null || !isAdmin(await getRole(auth.userId))) { + setResponseStatus(event, 403) + return + } + + const query = getQuery(event) + if (query.id == null) { + setResponseStatus(event, 400) + return + } + + const data = await prisma.fileUploadRecord.findMany({ + where: { + fileUploadId: String(query.id), + }, + include: { + club: { + select: { + name: true, + }, + }, + file: { + select: { + name: true, + }, + }, + }, + }) + return data +}) diff --git a/server/api/club/all-operating.get.ts b/server/api/club/all-operating.get.ts new file mode 100644 index 00000000..ddf1c8c7 --- /dev/null +++ b/server/api/club/all-operating.get.ts @@ -0,0 +1,27 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export default eventHandler(async (event) => { + const { auth } = event.context + + if ((auth?.userId) == null) { + setResponseStatus(event, 403) + return + } + + const data = await prisma.club.findMany({ + where: { + NOT: { + membersByTsimsStudentId: { + equals: [], + }, + }, + }, + select: { + name: true, + id: true, + }, + }) + return data +}) diff --git a/server/api/club/all_details.get.ts b/server/api/club/all_details.get.ts index 92ec8f8c..76006370 100644 --- a/server/api/club/all_details.get.ts +++ b/server/api/club/all_details.get.ts @@ -3,10 +3,10 @@ import { getStore } from '@netlify/blobs' export default defineCachedEventHandler(async (event) => { const { auth } = event.context - if (!auth.userId) { + if (auth?.userId == null) { setResponseStatus(event, 403) return } - return await getStore('enspire').get('clubs', { type: 'json' }) + return getStore('enspire').get('clubs', { type: 'json' }) }, { maxAge: 60 * 60 * 4 /* 4 hours */ }) diff --git a/server/api/user/check-role.get.ts b/server/api/user/check-role.get.ts new file mode 100644 index 00000000..634bdb2d --- /dev/null +++ b/server/api/user/check-role.get.ts @@ -0,0 +1,12 @@ +import { getRole } from '~~/utils/user-roles' + +export default eventHandler(async (event) => { + const { auth } = event.context + + if ((auth?.userId) == null) { + setResponseStatus(event, 403) + return + } + + return getRole(auth.userId) +}) diff --git a/utils/user-roles.ts b/utils/user-roles.ts new file mode 100644 index 00000000..bb8dd82f --- /dev/null +++ b/utils/user-roles.ts @@ -0,0 +1,39 @@ +import type { $Enums } from '@prisma/client' +import { PrismaClient, Roles } from '@prisma/client' + +const prisma = new PrismaClient() + +export interface getRoleResponse { + success: boolean + role: $Enums.Roles | null + error?: string +} + +export async function getRole(clerkId: string) { + try { + const user = await prisma.user.findFirstOrThrow({ + where: { + clerkUserId: clerkId, + }, + }) + return { + success: true, + role: user.role, + } + } + catch (error) { + return { + success: false, + role: null, + error: String(error), + } + } +} + +export function isRole(roleData: getRoleResponse | undefined, theRole: $Enums.Roles) { + return roleData?.success === true && roleData.role === theRole +} + +export function isAdmin(roleData: getRoleResponse | undefined) { + return isRole(roleData, Roles.ADMIN) +}