From 042e56c5f806c5db44be85017eedee98008a82bb Mon Sep 17 00:00:00 2001 From: q1zhen <142765265+q1zhen@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:47:03 +0000 Subject: [PATCH 1/3] feat: create admin page & auth --- app/components/custom/sidebar.vue | 43 ++++++- app/middleware/admin.ts | 33 +++++ app/pages/admin/manage-files.vue | 117 ++++++++++++++++++ .../20250110055647_user_roles/migration.sql | 5 + db/schema.prisma | 8 +- server/api/admin/all-records.get.ts | 38 ++++++ server/api/club/all-operating.get.ts | 27 ++++ server/api/club/all_details.get.ts | 4 +- server/api/user/check-role.get.ts | 12 ++ utils/user-roles.ts | 39 ++++++ 10 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 app/middleware/admin.ts create mode 100644 app/pages/admin/manage-files.vue create mode 100644 db/migrations/20250110055647_user_roles/migration.sql create mode 100644 server/api/admin/all-records.get.ts create mode 100644 server/api/club/all-operating.get.ts create mode 100644 server/api/user/check-role.get.ts create mode 100644 utils/user-roles.ts 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/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..19d8db78 --- /dev/null +++ b/app/pages/admin/manage-files.vue @@ -0,0 +1,117 @@ + + + + + + + + + + + + {{ collection.name }} + + + + + + + 社团 + 文件名 + 提交时间 + + + + + {{ record.club.name.zh }} + {{ record.file.name }} + {{ dayjs(record.createdAt).fromNow() }} + + + + + + + 未提交的社团 + + {{ unsubmittedClubs.length }} + + + + + {{ club.name.zh }} + + + + + + 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/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) +} From 55e73a17a8b37f11297f7bdc782092923877a717 Mon Sep 17 00:00:00 2001 From: q1zhen <142765265+q1zhen@users.noreply.github.com> Date: Sat, 11 Jan 2025 08:22:56 +0000 Subject: [PATCH 2/3] feat: files managing download & display --- app/components/ui/progress/Progress.vue | 39 ++++ app/components/ui/progress/index.ts | 1 + app/pages/admin/manage-files.vue | 250 +++++++++++++++++++----- package.json | 1 + pnpm-lock.yaml | 30 +++ 5 files changed, 270 insertions(+), 51 deletions(-) create mode 100644 app/components/ui/progress/Progress.vue create mode 100644 app/components/ui/progress/index.ts 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/pages/admin/manage-files.vue b/app/pages/admin/manage-files.vue index 19d8db78..c1fe1daf 100644 --- a/app/pages/admin/manage-files.vue +++ b/app/pages/admin/manage-files.vue @@ -1,6 +1,7 @@ - - - - - - - - - {{ collection.name }} - - - - - - - 社团 - 文件名 - 提交时间 - - - - - {{ record.club.name.zh }} - {{ record.file.name }} - {{ dayjs(record.createdAt).fromNow() }} - - - + + + 管理社团文件 - - - 未提交的社团 - - {{ unsubmittedClubs.length }} - + + + + + + + + + {{ collection.name }} + + + + + + + + 社团 + 文件名 + 提交时间 + + + + + 下载全部 + + + + + 下载中... + + {{ dlAllMsg }} + + + + + + 取消 + + + 完成 + + + + + + + + + + + + + + + + + {{ record.club.name.zh }} + {{ record.file.name }} + {{ dayjs(record.createdAt).fromNow() }} + + + 下载 + + + + + + - - - {{ club.name.zh }} - - - - - + + + 未提交的社团 + + {{ unsubmittedClubs.length }} + + + + + + + + + + {{ club.name.zh }} + + + + + + Download 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): From 9b56032229b17b6be63954c1f011cea98392f878 Mon Sep 17 00:00:00 2001 From: q1zhen <142765265+q1zhen@users.noreply.github.com> Date: Sat, 11 Jan 2025 08:23:16 +0000 Subject: [PATCH 3/3] feat: changeset --- .changeset/loud-otters-hide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/loud-otters-hide.md 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