From af72ab8e4967369f78b8048be9818d790b1268a1 Mon Sep 17 00:00:00 2001 From: Rocco Phoenix-Morrison Date: Thu, 1 Jan 2026 17:30:26 +0000 Subject: [PATCH 1/8] Init user roles --- app/components/navbar.vue | 12 + app/pages/admin.vue | 32 ++ server/api/user/[name]/user.get.ts | 26 ++ .../database/migrations/0008_init_roles.sql | 6 + .../migrations/meta/0008_snapshot.json | 293 ++++++++++++++++++ server/database/migrations/meta/_journal.json | 7 + server/database/schema.ts | 11 + 7 files changed, 387 insertions(+) create mode 100644 app/pages/admin.vue create mode 100644 server/api/user/[name]/user.get.ts create mode 100644 server/database/migrations/0008_init_roles.sql create mode 100644 server/database/migrations/meta/0008_snapshot.json diff --git a/app/components/navbar.vue b/app/components/navbar.vue index 092425e..f88541c 100644 --- a/app/components/navbar.vue +++ b/app/components/navbar.vue @@ -36,6 +36,17 @@ const createProject = async () => { { external: true }, ); }; + +let userRoles: string[] = []; +if (user.loggedIn) { + try { + const res = await $fetch<{ roles: string[] }>(`/api/user/${user.username}/user`, { + method: "GET", + headers: useRequestHeaders(["cookie"]), + }); + userRoles = res.roles ?? []; + } catch {} +} Create Explore + Moderate +const user = await useCurrentUser(); + +let userRoles: string[] = []; +if (user.loggedIn) { + try { + const res = await $fetch<{ roles: string[] }>(`/api/user/${user.username}/user`, { + method: "GET", + headers: useRequestHeaders(["cookie"]), + }); + userRoles = res.roles ?? []; + } catch {} +} +else { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); +} + +if (!userRoles.includes("admin")) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + }); +} + + + \ No newline at end of file diff --git a/server/api/user/[name]/user.get.ts b/server/api/user/[name]/user.get.ts new file mode 100644 index 0000000..68103a5 --- /dev/null +++ b/server/api/user/[name]/user.get.ts @@ -0,0 +1,26 @@ +import { db } from "../../../utils/drizzle"; +import * as schema from "../../../database/schema"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { eq } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const user = getRouterParam(event, "name") as string; + + const token = getCookie(event, "SB_TOKEN"); + + let decoded: string | JwtPayload | undefined; + const blankResponse = {roles:[]}; + + if (!token) return blankResponse; + try { + decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); + } catch { + return blankResponse; + } + + console.log(await db.select({ roles: schema.userRoles.roles }).from(schema.userRoles).where(eq(schema.userRoles.user, user))); + + return { + roles: (await db.select({ roles: schema.userRoles.roles }).from(schema.userRoles).where(eq(schema.userRoles.user, user)).limit(1)).at(0)?.roles ?? [], + }; +}); diff --git a/server/database/migrations/0008_init_roles.sql b/server/database/migrations/0008_init_roles.sql new file mode 100644 index 0000000..1598ea9 --- /dev/null +++ b/server/database/migrations/0008_init_roles.sql @@ -0,0 +1,6 @@ +CREATE TABLE `user_roles` ( + `user` text PRIMARY KEY NOT NULL, + `roles` text NOT NULL, + `created_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL, + `last_updated` integer DEFAULT (strftime('%s', 'now')) NOT NULL +); diff --git a/server/database/migrations/meta/0008_snapshot.json b/server/database/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..dccffa0 --- /dev/null +++ b/server/database/migrations/meta/0008_snapshot.json @@ -0,0 +1,293 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "50c47b62-f10c-47dd-a3d5-166e38d07c61", + "prevId": "21a289a5-59e9-4760-9170-9c06ac8e285f", + "tables": { + "project_comments": { + "name": "project_comments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "original_id": { + "name": "original_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "project_comments_project_id_projects_id_fk": { + "name": "project_comments_project_id_projects_id_fk", + "tableFrom": "project_comments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_likes": { + "name": "project_likes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_likes_project_id_projects_id_fk": { + "name": "project_likes_project_id_projects_id_fk", + "tableFrom": "project_likes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_likes_project_id_user_pk": { + "columns": [ + "project_id", + "user" + ], + "name": "project_likes_project_id_user_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_platforms": { + "name": "project_platforms", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_platforms_project_id_projects_id_fk": { + "name": "project_platforms_project_id_projects_id_fk", + "tableFrom": "project_platforms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_platforms_project_id_platform_pk": { + "columns": [ + "project_id", + "platform" + ], + "name": "project_platforms_project_id_platform_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private": { + "name": "private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unistore_data": { + "name": "unistore_data", + "columns": { + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "user": { + "name": "user", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json index 19d4ca6..6221229 100644 --- a/server/database/migrations/meta/_journal.json +++ b/server/database/migrations/meta/_journal.json @@ -47,6 +47,13 @@ "when": 1767219427039, "tag": "0007_comments_editing", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1767277948663, + "tag": "0008_init_roles", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/database/schema.ts b/server/database/schema.ts index 56fcfd0..074ea8b 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -44,3 +44,14 @@ export const projectComments = sqliteTable("project_comments", { export const unistoreData = sqliteTable("unistore_data", { revision: integer("revision").notNull(), }); + +export const userRoles = sqliteTable("user_roles", { + user: text("user").primaryKey(), + roles: text("roles", { mode: "json" }).notNull(), // array stored as JSON, allows multiple roles per user + createdAt: integer("created_at", { mode: "timestamp" }).default( + sql`(strftime('%s', 'now'))`, + ).notNull(), + lastUpdated: integer("last_updated", { mode: "timestamp" }).default( + sql`(strftime('%s', 'now'))`, + ).notNull(), +}); \ No newline at end of file From 8e1e1bfc5c051d37324d8bf8069e7ffe265d42d2 Mon Sep 17 00:00:00 2001 From: Rocco Phoenix-Morrison Date: Fri, 2 Jan 2026 00:03:56 +0000 Subject: [PATCH 2/8] Reorganise user_roles table Also added POST for roles, not in use yet though --- app/components/navbar.vue | 4 +- server/api/user/[name]/roles.get.ts | 42 ++++++++++++++ server/api/user/[name]/roles.post.ts | 55 +++++++++++++++++++ server/api/user/[name]/user.get.ts | 26 --------- .../database/migrations/0008_init_roles.sql | 10 ++-- .../migrations/meta/0008_snapshot.json | 38 +++++++++---- server/database/migrations/meta/_journal.json | 2 +- server/database/schema.ts | 17 +++--- 8 files changed, 142 insertions(+), 52 deletions(-) create mode 100644 server/api/user/[name]/roles.get.ts create mode 100644 server/api/user/[name]/roles.post.ts delete mode 100644 server/api/user/[name]/user.get.ts diff --git a/app/components/navbar.vue b/app/components/navbar.vue index f88541c..97f72e7 100644 --- a/app/components/navbar.vue +++ b/app/components/navbar.vue @@ -40,11 +40,11 @@ const createProject = async () => { let userRoles: string[] = []; if (user.loggedIn) { try { - const res = await $fetch<{ roles: string[] }>(`/api/user/${user.username}/user`, { + const res = await $fetch(`/api/user/${user.username}/roles`, { method: "GET", headers: useRequestHeaders(["cookie"]), }); - userRoles = res.roles ?? []; + userRoles = res ?? []; } catch {} } diff --git a/server/api/user/[name]/roles.get.ts b/server/api/user/[name]/roles.get.ts new file mode 100644 index 0000000..d3d2780 --- /dev/null +++ b/server/api/user/[name]/roles.get.ts @@ -0,0 +1,42 @@ +import { db } from "../../../utils/drizzle"; +import * as schema from "../../../database/schema"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { eq } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const user = getRouterParam(event, "name") as string; + const token = getCookie(event, "SB_TOKEN"); + let decoded: string | JwtPayload | undefined; + + if (!token) return []; + try { + decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); + } catch { + return []; + } + + const tokenUser = typeof decoded !== "string" ? decoded.username : null; + const tokenRoles = typeof decoded !== "string" ? ( + await db + .select({role: schema.userRoles.role}) + .from(schema.userRoles) + .where(eq(schema.userRoles.user, tokenUser)) + ).map(r => r.role) : []; + + const roles = ( + await db + .select({role: schema.userRoles.role}) + .from(schema.userRoles) + .where(eq(schema.userRoles.user, user)) + ).map(r => r.role); + + // prevent non-admin users from accessing other people's data + if (!tokenRoles.includes("admin") && user !== tokenUser) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden" + }); + } + + return roles; +}); diff --git a/server/api/user/[name]/roles.post.ts b/server/api/user/[name]/roles.post.ts new file mode 100644 index 0000000..860e3b8 --- /dev/null +++ b/server/api/user/[name]/roles.post.ts @@ -0,0 +1,55 @@ +import { db } from "../../../utils/drizzle"; +import * as schema from "../../../database/schema"; +import jwt, { JwtPayload } from "jsonwebtoken"; + +export default defineEventHandler(async (event) => { + const user = getRouterParam(event, "name") as string; + const token = getCookie(event, "SB_TOKEN"); + + if (!token) { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + + let decoded: string | JwtPayload; + try { + decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); + } catch (e) { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + + let body: { + role: string; + expiresAt?: number | null; + description?: string | null; + }; + try { + body = JSON.parse((await readRawBody(event)) as string); + } catch { + throw createError({ + statusCode: 500, + statusMessage: "Invalid body structure" + }); + } + if (!body) { + throw createError({ + statusCode: 400, + statusMessage: "No request body provided", + }); + } + + const { role, expiresAt, description } = body; + const result = await db.insert(schema.userRoles).values({ + user, + role, + expiresAt: expiresAt ? new Date(expiresAt) : null, + description + }); + + return result.lastInsertRowid; +}); \ No newline at end of file diff --git a/server/api/user/[name]/user.get.ts b/server/api/user/[name]/user.get.ts deleted file mode 100644 index 68103a5..0000000 --- a/server/api/user/[name]/user.get.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { db } from "../../../utils/drizzle"; -import * as schema from "../../../database/schema"; -import jwt, { JwtPayload } from "jsonwebtoken"; -import { eq } from "drizzle-orm"; - -export default defineEventHandler(async (event) => { - const user = getRouterParam(event, "name") as string; - - const token = getCookie(event, "SB_TOKEN"); - - let decoded: string | JwtPayload | undefined; - const blankResponse = {roles:[]}; - - if (!token) return blankResponse; - try { - decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); - } catch { - return blankResponse; - } - - console.log(await db.select({ roles: schema.userRoles.roles }).from(schema.userRoles).where(eq(schema.userRoles.user, user))); - - return { - roles: (await db.select({ roles: schema.userRoles.roles }).from(schema.userRoles).where(eq(schema.userRoles.user, user)).limit(1)).at(0)?.roles ?? [], - }; -}); diff --git a/server/database/migrations/0008_init_roles.sql b/server/database/migrations/0008_init_roles.sql index 1598ea9..536caf7 100644 --- a/server/database/migrations/0008_init_roles.sql +++ b/server/database/migrations/0008_init_roles.sql @@ -1,6 +1,8 @@ CREATE TABLE `user_roles` ( - `user` text PRIMARY KEY NOT NULL, - `roles` text NOT NULL, - `created_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL, - `last_updated` integer DEFAULT (strftime('%s', 'now')) NOT NULL + `user` text NOT NULL, + `role` text NOT NULL, + `added_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL, + `expires_at` integer, + `description` text, + PRIMARY KEY(`user`, `role`) ); diff --git a/server/database/migrations/meta/0008_snapshot.json b/server/database/migrations/meta/0008_snapshot.json index dccffa0..fe024e6 100644 --- a/server/database/migrations/meta/0008_snapshot.json +++ b/server/database/migrations/meta/0008_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "50c47b62-f10c-47dd-a3d5-166e38d07c61", + "id": "6ad7aa06-2209-4788-b654-7e096424834e", "prevId": "21a289a5-59e9-4760-9170-9c06ac8e285f", "tables": { "project_comments": { @@ -245,37 +245,51 @@ "user": { "name": "user", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true, "autoincrement": false }, - "roles": { - "name": "roles", + "role": { + "name": "role", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "created_at": { - "name": "created_at", + "added_at": { + "name": "added_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false, "default": "(strftime('%s', 'now'))" }, - "last_updated": { - "name": "last_updated", + "expires_at": { + "name": "expires_at", "type": "integer", "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(strftime('%s', 'now'))" + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false } }, "indexes": {}, "foreignKeys": {}, - "compositePrimaryKeys": {}, + "compositePrimaryKeys": { + "user_roles_user_role_pk": { + "columns": [ + "user", + "role" + ], + "name": "user_roles_user_role_pk" + } + }, "uniqueConstraints": {}, "checkConstraints": {} } diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json index 6221229..22b3098 100644 --- a/server/database/migrations/meta/_journal.json +++ b/server/database/migrations/meta/_journal.json @@ -51,7 +51,7 @@ { "idx": 8, "version": "6", - "when": 1767277948663, + "when": 1767307020651, "tag": "0008_init_roles", "breakpoints": true } diff --git a/server/database/schema.ts b/server/database/schema.ts index 074ea8b..edf8870 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -46,12 +46,15 @@ export const unistoreData = sqliteTable("unistore_data", { }); export const userRoles = sqliteTable("user_roles", { - user: text("user").primaryKey(), - roles: text("roles", { mode: "json" }).notNull(), // array stored as JSON, allows multiple roles per user - createdAt: integer("created_at", { mode: "timestamp" }).default( - sql`(strftime('%s', 'now'))`, - ).notNull(), - lastUpdated: integer("last_updated", { mode: "timestamp" }).default( + user: text("user").notNull(), + role: text("role").notNull(), + addedAt: integer("added_at", { mode: "timestamp" }).default( sql`(strftime('%s', 'now'))`, ).notNull(), -}); \ No newline at end of file + + // optional fields for bans, but no reason they couldn't be used for other roles + expiresAt: integer("expires_at", { mode: "timestamp" }), + description: text("description"), +}, (t) => [ + primaryKey({ columns: [t.user, t.role] }), +]); \ No newline at end of file From 334cd988b838bc7d492c5616e39a363afadfd4ec Mon Sep 17 00:00:00 2001 From: Rocco Phoenix-Morrison Date: Fri, 2 Jan 2026 00:45:11 +0000 Subject: [PATCH 3/8] Add role creation to admin page UI is functional but very ugly, holding off on fleshing this out until I know what all the page content will be --- README.md | 4 +- app/pages/admin.vue | 109 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 65f4f58..ef3634e 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ A WIP project hosting/distribution platform for Scratch Everywhere! - [ ] Reports - [ ] Comments - [ ] Projects - - [ ] Admin/Mod dashboard - - [ ] Easy way to make people mods/admins + - [x] Admin/Mod dashboard + - [x] Easy way to make people mods/admins - [ ] Allow mods to edit project info - [ ] Make them give a reason - [x] TOS/Rules diff --git a/app/pages/admin.vue b/app/pages/admin.vue index 9e04ab7..a739523 100644 --- a/app/pages/admin.vue +++ b/app/pages/admin.vue @@ -4,11 +4,11 @@ const user = await useCurrentUser(); let userRoles: string[] = []; if (user.loggedIn) { try { - const res = await $fetch<{ roles: string[] }>(`/api/user/${user.username}/user`, { + const res = await $fetch(`/api/user/${user.username}/roles`, { method: "GET", headers: useRequestHeaders(["cookie"]), }); - userRoles = res.roles ?? []; + userRoles = res ?? []; } catch {} } else { @@ -25,8 +25,109 @@ if (!userRoles.includes("admin")) { }); } +interface RoleFormState { + targetUsername: string; + selectedRole: string; + isLoading: boolean; + errorMessage: string; + successMessage: string; +} + +const formState = reactive({ + targetUsername: "", + selectedRole: "", + isLoading: false, + errorMessage: "", + successMessage: "", +}); + +const submitRole = async () => { + if (!formState.targetUsername.trim()) { + formState.errorMessage = "Please enter a username"; + return; + } + if (!formState.selectedRole) { + formState.errorMessage = "Please select a role"; + return; + } + + formState.isLoading = true; + formState.errorMessage = ""; + formState.successMessage = ""; + + try { + await $fetch(`/api/user/${formState.targetUsername}/roles`, { + method: "POST", + headers: useRequestHeaders(["cookie"]), + body: {role: formState.selectedRole}, + }); + formState.errorMessage = ""; + formState.successMessage = `Successfully assigned ${formState.selectedRole} role to ${formState.targetUsername}`; + formState.targetUsername = ""; + formState.selectedRole = ""; + } catch { + formState.successMessage = ""; + formState.errorMessage = "Failed to assign role"; + } finally { + formState.isLoading = false; + } +}; + \ No newline at end of file + +
+ + + +
+ +
+ {{ formState.errorMessage }} +
+
+ {{ formState.successMessage }} +
+ + \ No newline at end of file From 2e017f7777fba8a99f508fb9332c24d2481c223d Mon Sep 17 00:00:00 2001 From: Rocco Phoenix-Morrison Date: Sat, 3 Jan 2026 18:41:52 +0000 Subject: [PATCH 4/8] Add role deletion --- app/app.vue | 4 + app/pages/admin.vue | 113 ++++++++++++++++--------- server/api/user/[name]/roles.delete.ts | 66 +++++++++++++++ server/api/user/[name]/roles.get.ts | 7 +- server/api/user/[name]/roles.post.ts | 18 +++- 5 files changed, 166 insertions(+), 42 deletions(-) create mode 100644 server/api/user/[name]/roles.delete.ts diff --git a/app/app.vue b/app/app.vue index b1638de..0d4cb5f 100644 --- a/app/app.vue +++ b/app/app.vue @@ -43,6 +43,8 @@ body.theme-light { --color-primary-text: #fff; --color-text: #000; --color-blockquote: #d4d4d4; + --color-success: #3c3; + --color-error: #c33; } body.theme-dark { @@ -52,5 +54,7 @@ body.theme-dark { --color-primary-text: #fff; --color-text: #ccc; --color-blockquote: #333; + --color-success: #3c3; + --color-error: #c33; } diff --git a/app/pages/admin.vue b/app/pages/admin.vue index a739523..ff9cc16 100644 --- a/app/pages/admin.vue +++ b/app/pages/admin.vue @@ -41,7 +41,10 @@ const formState = reactive({ successMessage: "", }); -const submitRole = async () => { +const submitRole = async (remove: boolean) => { + formState.errorMessage = ""; + formState.successMessage = ""; + if (!formState.targetUsername.trim()) { formState.errorMessage = "Please enter a username"; return; @@ -55,19 +58,25 @@ const submitRole = async () => { formState.errorMessage = ""; formState.successMessage = ""; + let res; try { - await $fetch(`/api/user/${formState.targetUsername}/roles`, { - method: "POST", + res = await $fetch(`/api/user/${formState.targetUsername}/roles`, { + method: remove ? "DELETE" : "POST", headers: useRequestHeaders(["cookie"]), body: {role: formState.selectedRole}, }); - formState.errorMessage = ""; - formState.successMessage = `Successfully assigned ${formState.selectedRole} role to ${formState.targetUsername}`; - formState.targetUsername = ""; - formState.selectedRole = ""; + if (res) { + formState.successMessage = `Successfully ${remove ? "removed" : "assigned"} '${formState.selectedRole}' role for ${formState.targetUsername}`; + formState.targetUsername = ""; + formState.selectedRole = ""; + } + else { + formState.errorMessage = remove + ? `${formState.targetUsername} does not have the '${formState.selectedRole}' role` + : `Failed to assign role`; + } } catch { - formState.successMessage = ""; - formState.errorMessage = "Failed to assign role"; + formState.errorMessage = `Failed to ${remove ? "remove" : "assign"} role`; } finally { formState.isLoading = false; } @@ -75,45 +84,71 @@ const submitRole = async () => { \ No newline at end of file diff --git a/server/api/user/[name]/roles.delete.ts b/server/api/user/[name]/roles.delete.ts new file mode 100644 index 0000000..57c04ab --- /dev/null +++ b/server/api/user/[name]/roles.delete.ts @@ -0,0 +1,66 @@ +import { db } from "../../../utils/drizzle"; +import * as schema from "../../../database/schema"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { eq, and, or, isNull, gt } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const user = getRouterParam(event, "name") as string; + const token = getCookie(event, "SB_TOKEN"); + + if (!token) { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + + let decoded: string | JwtPayload; + try { + decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); + } catch (e) { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + + const tokenUser = typeof decoded !== "string" ? decoded.username : null; + const tokenRoles = typeof decoded !== "string" ? ( + await db + .select({role: schema.userRoles.role}) + .from(schema.userRoles) + .where(eq(schema.userRoles.user, tokenUser)) + ).map(r => r.role) : []; + + if (!tokenRoles.includes("admin")) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + }); + } + + const role: string = (JSON.parse(await readRawBody(event) as string)).role; + if (!role) { + throw createError({ + statusCode: 400, + statusMessage: "No role provided", + }); + } + + // set expiresAt to now + // means role is no longer active but we still have ban history + const result = await db + .update(schema.userRoles).set({ expiresAt: new Date() }) + .where( + and( + eq(schema.userRoles.user, user), + eq(schema.userRoles.role, role), + or( + isNull(schema.userRoles.expiresAt), + gt(schema.userRoles.expiresAt, new Date()) + ), + ) + ); + + return result.changes > 0; +}); \ No newline at end of file diff --git a/server/api/user/[name]/roles.get.ts b/server/api/user/[name]/roles.get.ts index d3d2780..88c3e72 100644 --- a/server/api/user/[name]/roles.get.ts +++ b/server/api/user/[name]/roles.get.ts @@ -1,7 +1,7 @@ import { db } from "../../../utils/drizzle"; import * as schema from "../../../database/schema"; import jwt, { JwtPayload } from "jsonwebtoken"; -import { eq } from "drizzle-orm"; +import { eq, gt, and, or, isNull } from "drizzle-orm"; export default defineEventHandler(async (event) => { const user = getRouterParam(event, "name") as string; @@ -27,7 +27,10 @@ export default defineEventHandler(async (event) => { await db .select({role: schema.userRoles.role}) .from(schema.userRoles) - .where(eq(schema.userRoles.user, user)) + .where(and(eq(schema.userRoles.user, user), or( + isNull(schema.userRoles.expiresAt), + gt(schema.userRoles.expiresAt, new Date()) + ))) ).map(r => r.role); // prevent non-admin users from accessing other people's data diff --git a/server/api/user/[name]/roles.post.ts b/server/api/user/[name]/roles.post.ts index 860e3b8..a520afa 100644 --- a/server/api/user/[name]/roles.post.ts +++ b/server/api/user/[name]/roles.post.ts @@ -1,6 +1,7 @@ import { db } from "../../../utils/drizzle"; import * as schema from "../../../database/schema"; import jwt, { JwtPayload } from "jsonwebtoken"; +import { eq } from "drizzle-orm"; export default defineEventHandler(async (event) => { const user = getRouterParam(event, "name") as string; @@ -23,6 +24,21 @@ export default defineEventHandler(async (event) => { }); } + const tokenUser = typeof decoded !== "string" ? decoded.username : null; + const tokenRoles = typeof decoded !== "string" ? ( + await db + .select({role: schema.userRoles.role}) + .from(schema.userRoles) + .where(eq(schema.userRoles.user, tokenUser)) + ).map(r => r.role) : []; + + if (!tokenRoles.includes("admin")) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + }); + } + let body: { role: string; expiresAt?: number | null; @@ -32,7 +48,7 @@ export default defineEventHandler(async (event) => { body = JSON.parse((await readRawBody(event)) as string); } catch { throw createError({ - statusCode: 500, + statusCode: 400, statusMessage: "Invalid body structure" }); } From d49e644247bedc61a7180dff4ba9d5b859c9f886 Mon Sep 17 00:00:00 2001 From: Rocco Phoenix-Morrison Date: Sun, 4 Jan 2026 21:17:03 +0000 Subject: [PATCH 5/8] Improve admin UI Adds endpoint to GET all user roles, rather than specific user roles. Only returns to users with admin role --- app/pages/admin.vue | 225 +++++++++++++++++++++++++++++------ server/api/user/roles.get.ts | 43 +++++++ 2 files changed, 233 insertions(+), 35 deletions(-) create mode 100644 server/api/user/roles.get.ts diff --git a/app/pages/admin.vue b/app/pages/admin.vue index ff9cc16..12252b8 100644 --- a/app/pages/admin.vue +++ b/app/pages/admin.vue @@ -25,12 +25,52 @@ if (!userRoles.includes("admin")) { }); } +interface RoleReturn { + role: string; + user: string; + expiresAt: string | null; +} + +const allRoles = ref(await $fetch("/api/user/roles", { + method: "GET", + headers: useRequestHeaders(["cookie"]), +})); + +const roleProfiles = ref([]); + +watch(() => allRoles.value, async () => { + if (allRoles.value.length === 0) { + roleProfiles.value = []; + return; + } + + roleProfiles.value = await Promise.all( + allRoles.value.map((r) => + getProfilePicture(r.user).catch(() => null) // handle errors per request + ), + ) as string[]; +}, { immediate: true }); + +const deleteForm = ref(null); +const deleteFormOpen = ref(false); +watch (deleteForm, async () => { + deleteFormOpen.value = deleteForm.value !== null; +}); + +const createFormOpen = ref(false); + +const setSelectedRole = (index: number) => { + const role = allRoles.value[index]; + formState.targetUsername = role!.user; + formState.selectedRole = role!.role; + deleteForm.value = index; +}; + interface RoleFormState { targetUsername: string; selectedRole: string; isLoading: boolean; errorMessage: string; - successMessage: string; } const formState = reactive({ @@ -38,12 +78,10 @@ const formState = reactive({ selectedRole: "", isLoading: false, errorMessage: "", - successMessage: "", }); const submitRole = async (remove: boolean) => { formState.errorMessage = ""; - formState.successMessage = ""; if (!formState.targetUsername.trim()) { formState.errorMessage = "Please enter a username"; @@ -56,7 +94,6 @@ const submitRole = async (remove: boolean) => { formState.isLoading = true; formState.errorMessage = ""; - formState.successMessage = ""; let res; try { @@ -66,7 +103,18 @@ const submitRole = async (remove: boolean) => { body: {role: formState.selectedRole}, }); if (res) { - formState.successMessage = `Successfully ${remove ? "removed" : "assigned"} '${formState.selectedRole}' role for ${formState.targetUsername}`; + if (deleteForm.value !== null) { + allRoles.value.splice(deleteForm.value, 1); + deleteForm.value = null; + } else if (createFormOpen.value) { + // Refetch to get complete data from server + allRoles.value = await $fetch("/api/user/roles", { + method: "GET", + headers: useRequestHeaders(["cookie"]), + }); + createFormOpen.value = false; + } + formState.targetUsername = ""; formState.selectedRole = ""; } @@ -87,36 +135,73 @@ const submitRole = async (remove: boolean) => {

Admin Panel

-

Modify roles

-
- - - - -
+
+
+ + + {{ role.user }} + +

{{ role.role }}

+ + +

until

+

{{ + (() => { + const date = new Date(role.expiresAt); + const weekday = date.toLocaleDateString(undefined, { weekday: "long" }); + const day = date.getDate(); + const getDaySuffix = (d: number) => { + if (d > 3 && d < 21) return "th"; + switch (d % 10) { + case 1: return "st"; + case 2: return "nd"; + case 3: return "rd"; + default: return "th"; + } + }; + const suffix = getDaySuffix(day); + const month = date.toLocaleDateString(undefined, { month: "long" }); + const year = date.getFullYear(); + + let hours = date.getHours(); + const minutes = date.getMinutes().toString().padStart(2, "0"); + const ampm = hours >= 12 ? "PM" : "AM"; + hours = hours % 12; + hours = hours ? hours : 12; // the hour '0' should be '12' -

- {{ formState.errorMessage }} -
-
- {{ formState.successMessage }} -
+ return `${weekday} ${day}${suffix} ${month} ${year} at ${hours}:${minutes} ${ampm}`; + })() + }}

+
+
+ + +
+
+ + + +

{{ formState.errorMessage }}

+
+ + + + + + +

{{ formState.errorMessage }}

+
\ No newline at end of file diff --git a/server/api/user/roles.get.ts b/server/api/user/roles.get.ts new file mode 100644 index 0000000..4cd2a4e --- /dev/null +++ b/server/api/user/roles.get.ts @@ -0,0 +1,43 @@ +import { db } from "../../utils/drizzle"; +import * as schema from "../../database/schema"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { eq, gt, or, isNull } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const user = getRouterParam(event, "name") as string; + const token = getCookie(event, "SB_TOKEN"); + let decoded: string | JwtPayload | undefined; + + if (!token) return []; + try { + decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); + } catch { + return []; + } + + const tokenUser = typeof decoded !== "string" ? decoded.username : null; + const tokenRoles = typeof decoded !== "string" ? ( + await db + .select({role: schema.userRoles.role}) + .from(schema.userRoles) + .where(eq(schema.userRoles.user, tokenUser)) + ).map(r => r.role) : []; + + // prevent non-admin users from accessing other people's data + if (!tokenRoles.includes("admin") && user !== tokenUser) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden" + }); + } + + const roles = await db + .select({ user: schema.userRoles.user, role: schema.userRoles.role, expiresAt: schema.userRoles.expiresAt }) + .from(schema.userRoles) + .where(or( + isNull(schema.userRoles.expiresAt), + gt(schema.userRoles.expiresAt, new Date()) + )); + + return roles; +}); From cd3f2c616c97c40d6d93e36ff4786e2a4561f3f8 Mon Sep 17 00:00:00 2001 From: Rocco Phoenix-Morrison Date: Mon, 5 Jan 2026 22:35:41 +0000 Subject: [PATCH 6/8] Add expiry and description to role creation --- app/app.vue | 2 + app/pages/admin.vue | 115 ++++++++++++++++----------- server/api/user/[name]/roles.post.ts | 9 ++- server/api/user/roles.get.ts | 7 +- 4 files changed, 85 insertions(+), 48 deletions(-) diff --git a/app/app.vue b/app/app.vue index 0d4cb5f..3f07b47 100644 --- a/app/app.vue +++ b/app/app.vue @@ -45,6 +45,7 @@ body.theme-light { --color-blockquote: #d4d4d4; --color-success: #3c3; --color-error: #c33; + --color-error-background: #fcc; } body.theme-dark { @@ -56,5 +57,6 @@ body.theme-dark { --color-blockquote: #333; --color-success: #3c3; --color-error: #c33; + --color-error-background: #422; } diff --git a/app/pages/admin.vue b/app/pages/admin.vue index 12252b8..547f0e6 100644 --- a/app/pages/admin.vue +++ b/app/pages/admin.vue @@ -69,17 +69,31 @@ const setSelectedRole = (index: number) => { interface RoleFormState { targetUsername: string; selectedRole: string; + expiryDate: number | null; isLoading: boolean; errorMessage: string; + description: string; } const formState = reactive({ targetUsername: "", selectedRole: "", + expiryDate: null, + description: "", isLoading: false, errorMessage: "", }); +const resetForm = () => { + formState.targetUsername = ""; + formState.selectedRole = ""; + formState.expiryDate = null; + formState.description = ""; + createFormOpen.value = false; + deleteForm.value = null; + formState.errorMessage = ""; +}; + const submitRole = async (remove: boolean) => { formState.errorMessage = ""; @@ -91,32 +105,32 @@ const submitRole = async (remove: boolean) => { formState.errorMessage = "Please select a role"; return; } + if (formState.expiryDate && new Date(formState.expiryDate).getTime() < Date.now()) { + formState.errorMessage = "Expiry date cannot be in the past"; + return; + } formState.isLoading = true; - formState.errorMessage = ""; let res; try { res = await $fetch(`/api/user/${formState.targetUsername}/roles`, { method: remove ? "DELETE" : "POST", headers: useRequestHeaders(["cookie"]), - body: {role: formState.selectedRole}, + body: {role: formState.selectedRole, expiresAt: formState.expiryDate, description: formState.description}, }); if (res) { - if (deleteForm.value !== null) { + if (deleteFormOpen.value) { allRoles.value.splice(deleteForm.value, 1); - deleteForm.value = null; } else if (createFormOpen.value) { - // Refetch to get complete data from server + // Refetch (for now) + // TODO: update changed instead of refetching all allRoles.value = await $fetch("/api/user/roles", { method: "GET", headers: useRequestHeaders(["cookie"]), }); - createFormOpen.value = false; } - - formState.targetUsername = ""; - formState.selectedRole = ""; + resetForm(); } else { formState.errorMessage = remove @@ -130,6 +144,27 @@ const submitRole = async (remove: boolean) => { } }; +const formatExpiryDate = (dateString: string) => { + const date = new Date(dateString); + const day = date.getDate(); + const getDaySuffix = (d: number) => { + if (d > 3 && d < 21) return "th"; + switch (d % 10) { + case 1: return "st"; + case 2: return "nd"; + case 3: return "rd"; + default: return "th"; + } + }; + const month = date.toLocaleDateString(undefined, { month: "long" }); + const year = date.getFullYear(); + const hours = date.getHours() % 12 || 12; + const minutes = date.getMinutes().toString().padStart(2, "0"); + const ampm = date.getHours() >= 12 ? "PM" : "AM"; + + return `${day}${getDaySuffix(day)} ${month} ${year}, ${hours}:${minutes} ${ampm}`; +}; + diff --git a/app/pages/admin.vue b/app/pages/admin.vue index cc354af..0a7d91b 100644 --- a/app/pages/admin.vue +++ b/app/pages/admin.vue @@ -233,7 +233,14 @@ const formatExpiryDate = (dateString: string) => { padding: 0.25rem; border-radius: 0.25rem; font-size: 1rem; - color: black; + color: var(--color-text); + background-color: var(--color-background); + border: 2px solid var(--color-background); + } + + input:focus, select:focus { + outline: none; + border-color: var(--color-primary); } button { padding: 0.25rem; @@ -272,7 +279,7 @@ const formatExpiryDate = (dateString: string) => { margin-top: 1rem; border: 0.25rem solid var(--color-secondary-background); border-radius: 0.5rem; - background-color: var(--color-primary-text); + background-color: var(--color-card-background); padding: 1rem; & .role-text {