From e1f40caa796d40c7a93aae0b4b27b8465420911e Mon Sep 17 00:00:00 2001 From: Robert Clabough Date: Mon, 27 Jul 2026 13:59:57 -0700 Subject: [PATCH 1/7] Adding groups logic and restrictions --- server/i18n/locales/en_us.json | 23 +- server/pages/admin/users/groups/[id].vue | 356 ++++++++++++++++++ server/pages/admin/users/index.vue | 247 +++++++++++- .../migration.sql | 58 +++ server/prisma/models/group.prisma | 23 ++ server/prisma/models/user.prisma | 2 + .../api/v1/admin/groups/[id]/index.delete.ts | 18 + .../api/v1/admin/groups/[id]/index.get.ts | 28 ++ .../api/v1/admin/groups/[id]/index.patch.ts | 37 ++ .../api/v1/admin/groups/[id]/members.patch.ts | 40 ++ .../api/v1/admin/groups/[id]/ratings.patch.ts | 63 ++++ .../server/api/v1/admin/groups/index.get.ts | 21 ++ .../server/api/v1/admin/groups/index.post.ts | 31 ++ .../server/api/v1/client/user/library.get.ts | 4 +- .../api/v1/collection/[id]/index.get.ts | 11 +- .../api/v1/collection/default/index.get.ts | 8 +- server/server/api/v1/collection/index.get.ts | 11 +- server/server/api/v1/games/[id]/index.get.ts | 14 +- server/server/api/v1/store/featured.get.ts | 8 +- server/server/api/v1/store/index.get.ts | 9 +- server/server/internal/auth/oidc/index.ts | 31 +- server/server/internal/userlibrary/index.ts | 31 +- .../server/internal/utils/ageRestrictions.ts | 48 +++ 23 files changed, 1089 insertions(+), 33 deletions(-) create mode 100644 server/pages/admin/users/groups/[id].vue create mode 100644 server/prisma/migrations/20260726041153_add_user_groups/migration.sql create mode 100644 server/prisma/models/group.prisma create mode 100644 server/server/api/v1/admin/groups/[id]/index.delete.ts create mode 100644 server/server/api/v1/admin/groups/[id]/index.get.ts create mode 100644 server/server/api/v1/admin/groups/[id]/index.patch.ts create mode 100644 server/server/api/v1/admin/groups/[id]/members.patch.ts create mode 100644 server/server/api/v1/admin/groups/[id]/ratings.patch.ts create mode 100644 server/server/api/v1/admin/groups/index.get.ts create mode 100644 server/server/api/v1/admin/groups/index.post.ts create mode 100644 server/server/internal/utils/ageRestrictions.ts diff --git a/server/i18n/locales/en_us.json b/server/i18n/locales/en_us.json index e3f0becf0..cfc827122 100644 --- a/server/i18n/locales/en_us.json +++ b/server/i18n/locales/en_us.json @@ -850,7 +850,28 @@ "userInvitation": "User invitation" }, "srEditLabel": "Edit", - "usernameHeader": "Username" + "usernameHeader": "Username", + "groups": { + "title": "User Groups", + "description": "Manage user groups and age rating restrictions.", + "createGroup": "Create Group", + "name": "Name", + "descriptionField": "Description", + "details": "Details", + "members": "Members", + "addMember": "Select a user...", + "removeMember": "Remove", + "noMembers": "No members in this group.", + "oidcManagedNote": "Group membership is managed by your OIDC provider. Members are automatically synced when users log in. To change group membership, update your identity provider's group assignments.", + "bannedRatings": "Banned Ratings", + "addBannedRating": "Add banned rating", + "removeBannedRating": "Remove", + "noBannedRatings": "No banned ratings configured.", + "unratedNote": "Games without age ratings will also be hidden from users in this group.", + "deleteConfirm": "Are you sure you want to delete this group? This action cannot be undone.", + "deleteGroup": "Delete", + "groupsLink": "Groups {arrow}" + } } }, "welcome": "American, Welcome!" diff --git a/server/pages/admin/users/groups/[id].vue b/server/pages/admin/users/groups/[id].vue new file mode 100644 index 000000000..b6f880374 --- /dev/null +++ b/server/pages/admin/users/groups/[id].vue @@ -0,0 +1,356 @@ + + + diff --git a/server/pages/admin/users/index.vue b/server/pages/admin/users/index.vue index 1ef804b26..06724336f 100644 --- a/server/pages/admin/users/index.vue +++ b/server/pages/admin/users/index.vue @@ -22,6 +22,8 @@ + +
@@ -122,14 +124,6 @@ > {{ $t("users.admin.delete") }} - - @@ -139,6 +133,202 @@
+ + +
+
+
+

+ {{ $t("users.admin.groups.title") }} +

+

+ {{ $t("users.admin.groups.description") }} +

+
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+ {{ $t("users.admin.groups.name") }} + + {{ $t("users.admin.groups.descriptionField") }} + + {{ $t("users.admin.groups.members") }} + + {{ $t("users.admin.groups.bannedRatings") }} + + Actions +
+ {{ group.name }} + + {{ group.description || "-" }} + + {{ group._count.users }} + + {{ group._count.bannedAgeRatings }} + + + {{ $t("common.edit") }} + + +
+
+
+
+
+
+ + +
+
+

+ {{ $t("users.admin.groups.createGroup") }} +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

+ {{ $t("users.admin.groups.deleteGroup") }} +

+

+ {{ $t("users.admin.groups.deleteConfirm") }} +

+
+ + +
+
+
@@ -162,6 +352,45 @@ if (!users.value) { } const userToDelete = ref(); - const setUserToDelete = (user: UserModel) => (userToDelete.value = user); + +// Groups +interface GroupListItem { + id: string; + name: string; + description: string; + _count: { users: number; bannedAgeRatings: number }; +} + +const groups = ref([]); +const showCreateGroup = ref(false); +const newGroupName = ref(""); +const newGroupDescription = ref(""); +const groupToDelete = ref(); + +const fetchGroups = async () => { + groups.value = await $dropFetch("/api/v1/admin/groups"); +}; + +await fetchGroups(); + +const createGroup = async () => { + await $dropFetch("/api/v1/admin/groups", { + method: "POST", + body: { name: newGroupName.value, description: newGroupDescription.value }, + }); + showCreateGroup.value = false; + newGroupName.value = ""; + newGroupDescription.value = ""; + await fetchGroups(); +}; + +const deleteGroup = async () => { + if (!groupToDelete.value) return; + await $dropFetch(`/api/v1/admin/groups/${groupToDelete.value.id}`, { + method: "DELETE", + }); + groupToDelete.value = undefined; + await fetchGroups(); +}; diff --git a/server/prisma/migrations/20260726041153_add_user_groups/migration.sql b/server/prisma/migrations/20260726041153_add_user_groups/migration.sql new file mode 100644 index 000000000..afebe02cf --- /dev/null +++ b/server/prisma/migrations/20260726041153_add_user_groups/migration.sql @@ -0,0 +1,58 @@ +-- DropIndex +DROP INDEX "Game_mName_idx"; + +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- CreateTable +CREATE TABLE "UserGroup" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BannedAgeRating" ( + "id" TEXT NOT NULL, + "organization" "AgeRatingOrganization" NOT NULL, + "rating" TEXT NOT NULL, + "userGroupId" TEXT NOT NULL, + + CONSTRAINT "BannedAgeRating_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_UserToUserGroup" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_UserToUserGroup_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserGroup_name_key" ON "UserGroup"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "BannedAgeRating_userGroupId_organization_rating_key" ON "BannedAgeRating"("userGroupId", "organization", "rating"); + +-- CreateIndex +CREATE INDEX "_UserToUserGroup_B_index" ON "_UserToUserGroup"("B"); + +-- CreateIndex +CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32)); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); + +-- AddForeignKey +ALTER TABLE "BannedAgeRating" ADD CONSTRAINT "BannedAgeRating_userGroupId_fkey" FOREIGN KEY ("userGroupId") REFERENCES "UserGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_UserToUserGroup" ADD CONSTRAINT "_UserToUserGroup_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_UserToUserGroup" ADD CONSTRAINT "_UserToUserGroup_B_fkey" FOREIGN KEY ("B") REFERENCES "UserGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/models/group.prisma b/server/prisma/models/group.prisma new file mode 100644 index 000000000..e866ac298 --- /dev/null +++ b/server/prisma/models/group.prisma @@ -0,0 +1,23 @@ +model UserGroup { + id String @id @default(uuid()) + name String @unique + description String @default("") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] + bannedAgeRatings BannedAgeRating[] +} + +model BannedAgeRating { + id String @id @default(uuid()) + + organization AgeRatingOrganization + rating String + + userGroup UserGroup @relation(fields: [userGroupId], references: [id], onDelete: Cascade) + userGroupId String + + @@unique([userGroupId, organization, rating], name: "groupRatingKey") +} diff --git a/server/prisma/models/user.prisma b/server/prisma/models/user.prisma index cf1fd26a2..74d82c2de 100644 --- a/server/prisma/models/user.prisma +++ b/server/prisma/models/user.prisma @@ -22,6 +22,8 @@ model User { saves SaveSlot[] screenshots Screenshot[] playtime Playtime[] + + groups UserGroup[] } model Notification { diff --git a/server/server/api/v1/admin/groups/[id]/index.delete.ts b/server/server/api/v1/admin/groups/[id]/index.delete.ts new file mode 100644 index 000000000..0a720dbc2 --- /dev/null +++ b/server/server/api/v1/admin/groups/[id]/index.delete.ts @@ -0,0 +1,18 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + + const group = await prisma.userGroup.findUnique({ where: { id } }); + if (!group) + throw createError({ statusCode: 404, message: "Group not found" }); + + // eslint-disable-next-line drop/no-prisma-delete + await prisma.userGroup.delete({ where: { id } }); + + return { success: true }; +}); diff --git a/server/server/api/v1/admin/groups/[id]/index.get.ts b/server/server/api/v1/admin/groups/[id]/index.get.ts new file mode 100644 index 000000000..fe88aa559 --- /dev/null +++ b/server/server/api/v1/admin/groups/[id]/index.get.ts @@ -0,0 +1,28 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["user:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + + const group = await prisma.userGroup.findUnique({ + where: { id }, + include: { + users: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + bannedAgeRatings: true, + }, + }); + + if (!group) + throw createError({ statusCode: 404, message: "Group not found" }); + + return group; +}); diff --git a/server/server/api/v1/admin/groups/[id]/index.patch.ts b/server/server/api/v1/admin/groups/[id]/index.patch.ts new file mode 100644 index 000000000..8dc247349 --- /dev/null +++ b/server/server/api/v1/admin/groups/[id]/index.patch.ts @@ -0,0 +1,37 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const PatchGroup = type({ + name: "2 <= string <= 50", + description: "string = ''", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + const body = await readDropValidatedBody(h3, PatchGroup); + + const existing = await prisma.userGroup.findUnique({ where: { id } }); + if (!existing) + throw createError({ statusCode: 404, message: "Group not found" }); + + const nameTaken = await prisma.userGroup.findFirst({ + where: { name: body.name, id: { not: id } }, + }); + if (nameTaken) + throw createError({ statusCode: 400, message: "Group name already exists" }); + + const group = await prisma.userGroup.update({ + where: { id }, + data: { + name: body.name, + description: body.description, + }, + }); + + return group; +}); diff --git a/server/server/api/v1/admin/groups/[id]/members.patch.ts b/server/server/api/v1/admin/groups/[id]/members.patch.ts new file mode 100644 index 000000000..aa0c1a8f1 --- /dev/null +++ b/server/server/api/v1/admin/groups/[id]/members.patch.ts @@ -0,0 +1,40 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const PatchMembers = type({ + userIds: "string[]", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + const body = await readDropValidatedBody(h3, PatchMembers); + + const group = await prisma.userGroup.findUnique({ where: { id } }); + if (!group) + throw createError({ statusCode: 404, message: "Group not found" }); + + await prisma.userGroup.update({ + where: { id }, + data: { + users: { set: body.userIds.map((uid) => ({ id: uid })) }, + }, + }); + + return await prisma.userGroup.findUnique({ + where: { id }, + include: { + users: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + }); +}); diff --git a/server/server/api/v1/admin/groups/[id]/ratings.patch.ts b/server/server/api/v1/admin/groups/[id]/ratings.patch.ts new file mode 100644 index 000000000..15d64cb01 --- /dev/null +++ b/server/server/api/v1/admin/groups/[id]/ratings.patch.ts @@ -0,0 +1,63 @@ +import { type } from "arktype"; +import type { AgeRatingOrganization } from "~/prisma/client/enums"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import { + getAvailableRatings, + RATINGS_FOR_ORGANIZATION, +} from "~/utils/ageRatings"; + +const PatchRatings = type({ + bannedRatings: type({ + organization: "string", + rating: "string", + }).array(), +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + const body = await readDropValidatedBody(h3, PatchRatings); + + const group = await prisma.userGroup.findUnique({ where: { id } }); + if (!group) + throw createError({ statusCode: 404, message: "Group not found" }); + + for (const br of body.bannedRatings) { + if (!(br.organization in RATINGS_FOR_ORGANIZATION)) { + throw createError({ + statusCode: 400, + message: `Invalid organization: ${br.organization}`, + }); + } + const validRatings = getAvailableRatings( + br.organization as AgeRatingOrganization, + ); + if (!validRatings.includes(br.rating)) { + throw createError({ + statusCode: 400, + message: `Invalid rating "${br.rating}" for ${br.organization}`, + }); + } + } + + await prisma.$transaction([ + prisma.bannedAgeRating.deleteMany({ + where: { userGroupId: id }, + }), + prisma.bannedAgeRating.createMany({ + data: body.bannedRatings.map((br) => ({ + userGroupId: id, + organization: br.organization as AgeRatingOrganization, + rating: br.rating, + })), + }), + ]); + + return await prisma.bannedAgeRating.findMany({ + where: { userGroupId: id }, + }); +}); diff --git a/server/server/api/v1/admin/groups/index.get.ts b/server/server/api/v1/admin/groups/index.get.ts new file mode 100644 index 000000000..0a084de07 --- /dev/null +++ b/server/server/api/v1/admin/groups/index.get.ts @@ -0,0 +1,21 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["user:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const groups = await prisma.userGroup.findMany({ + include: { + _count: { + select: { + users: true, + bannedAgeRatings: true, + }, + }, + }, + orderBy: { name: "asc" }, + }); + + return groups; +}); diff --git a/server/server/api/v1/admin/groups/index.post.ts b/server/server/api/v1/admin/groups/index.post.ts new file mode 100644 index 000000000..4f2e229f9 --- /dev/null +++ b/server/server/api/v1/admin/groups/index.post.ts @@ -0,0 +1,31 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const CreateGroup = type({ + name: "2 <= string <= 50", + description: "string = ''", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readDropValidatedBody(h3, CreateGroup); + + const existing = await prisma.userGroup.findUnique({ + where: { name: body.name }, + }); + if (existing) + throw createError({ statusCode: 400, message: "Group name already exists" }); + + const group = await prisma.userGroup.create({ + data: { + name: body.name, + description: body.description, + }, + }); + + return group; +}); diff --git a/server/server/api/v1/client/user/library.get.ts b/server/server/api/v1/client/user/library.get.ts index 4870e2c0b..0753b5326 100644 --- a/server/server/api/v1/client/user/library.get.ts +++ b/server/server/api/v1/client/user/library.get.ts @@ -1,8 +1,10 @@ import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; import userLibraryManager from "~/server/internal/userlibrary"; +import { getAgeRestrictionFilter } from "~/server/internal/utils/ageRestrictions"; export default defineClientEventHandler(async (_h3, { fetchUser }) => { const user = await fetchUser(); - const library = await userLibraryManager.fetchLibrary(user.id); + const ageFilter = await getAgeRestrictionFilter(user.id, user.admin); + const library = await userLibraryManager.fetchLibrary(user.id, ageFilter); return library.entries.map((e) => e.game); }); diff --git a/server/server/api/v1/collection/[id]/index.get.ts b/server/server/api/v1/collection/[id]/index.get.ts index 9bb185403..305fbcdea 100644 --- a/server/server/api/v1/collection/[id]/index.get.ts +++ b/server/server/api/v1/collection/[id]/index.get.ts @@ -1,9 +1,10 @@ import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; +import { getAgeRestrictionFilter } from "~/server/internal/utils/ageRestrictions"; export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); - if (!userId) + const user = await aclManager.getUserACL(h3, ["collections:read"]); + if (!user) throw createError({ statusCode: 403, statusMessage: "Requires authentication", @@ -16,9 +17,11 @@ export default defineEventHandler(async (h3) => { statusMessage: "ID required in route params", }); + const ageFilter = await getAgeRestrictionFilter(user.id, user.admin); + // Fetch specific collection // Will not return the default collection - const collection = await userLibraryManager.fetchCollection(id); + const collection = await userLibraryManager.fetchCollection(id, ageFilter); if (!collection) throw createError({ statusCode: 404, @@ -26,7 +29,7 @@ export default defineEventHandler(async (h3) => { }); // Verify user owns this collection - if (collection.userId !== userId) + if (collection.userId !== user.id) throw createError({ statusCode: 403, statusMessage: "Not authorized to access this collection", diff --git a/server/server/api/v1/collection/default/index.get.ts b/server/server/api/v1/collection/default/index.get.ts index 22a357d5b..3fd9f4ebe 100644 --- a/server/server/api/v1/collection/default/index.get.ts +++ b/server/server/api/v1/collection/default/index.get.ts @@ -1,15 +1,17 @@ import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; +import { getAgeRestrictionFilter } from "~/server/internal/utils/ageRestrictions"; export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); - if (!userId) + const user = await aclManager.getUserACL(h3, ["collections:read"]); + if (!user) throw createError({ statusCode: 403, statusMessage: "Requires authentication", }); - const collection = await userLibraryManager.fetchLibrary(userId); + const ageFilter = await getAgeRestrictionFilter(user.id, user.admin); + const collection = await userLibraryManager.fetchLibrary(user.id, ageFilter); return collection; }); diff --git a/server/server/api/v1/collection/index.get.ts b/server/server/api/v1/collection/index.get.ts index 2fbe41b78..0e00f74c3 100644 --- a/server/server/api/v1/collection/index.get.ts +++ b/server/server/api/v1/collection/index.get.ts @@ -1,13 +1,18 @@ import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; +import { getAgeRestrictionFilter } from "~/server/internal/utils/ageRestrictions"; export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); - if (!userId) + const user = await aclManager.getUserACL(h3, ["collections:read"]); + if (!user) throw createError({ statusCode: 403, }); - const collections = await userLibraryManager.fetchCollections(userId); + const ageFilter = await getAgeRestrictionFilter(user.id, user.admin); + const collections = await userLibraryManager.fetchCollections( + user.id, + ageFilter, + ); return collections; }); diff --git a/server/server/api/v1/games/[id]/index.get.ts b/server/server/api/v1/games/[id]/index.get.ts index b558e87c3..bb8ebed56 100644 --- a/server/server/api/v1/games/[id]/index.get.ts +++ b/server/server/api/v1/games/[id]/index.get.ts @@ -1,10 +1,11 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import gameSizeManager from "~/server/internal/gamesize"; +import { getAgeRestrictionFilter } from "~/server/internal/utils/ageRestrictions"; export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserIdACL(h3, ["store:read"]); - if (!userId) throw createError({ statusCode: 403 }); + const user = await aclManager.getUserACL(h3, ["store:read"]); + if (!user) throw createError({ statusCode: 403 }); const gameId = getRouterParam(h3, "id"); if (!gameId) @@ -52,6 +53,15 @@ export default defineEventHandler(async (h3) => { if (!game) throw createError({ statusCode: 404, statusMessage: "Game not found" }); + const ageFilter = await getAgeRestrictionFilter(user.id, user.admin); + if (ageFilter) { + const allowed = await prisma.game.count({ + where: { id: gameId, ...ageFilter }, + }); + if (allowed === 0) + throw createError({ statusCode: 404, statusMessage: "Game not found" }); + } + const rating = await prisma.gameRating.aggregate({ where: { gameId: game.id, diff --git a/server/server/api/v1/store/featured.get.ts b/server/server/api/v1/store/featured.get.ts index fb353e410..e2f1c1596 100644 --- a/server/server/api/v1/store/featured.get.ts +++ b/server/server/api/v1/store/featured.get.ts @@ -1,13 +1,17 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; +import { getAgeRestrictionFilter } from "~/server/internal/utils/ageRestrictions"; export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserACL(h3, ["store:read"]); - if (!userId) throw createError({ statusCode: 403 }); + const user = await aclManager.getUserACL(h3, ["store:read"]); + if (!user) throw createError({ statusCode: 403 }); + + const ageFilter = await getAgeRestrictionFilter(user.id, user.admin); const games = await prisma.game.findMany({ where: { featured: true, + ...ageFilter, }, select: { id: true, diff --git a/server/server/api/v1/store/index.get.ts b/server/server/api/v1/store/index.get.ts index 5e38581df..b7b4e78dc 100644 --- a/server/server/api/v1/store/index.get.ts +++ b/server/server/api/v1/store/index.get.ts @@ -4,6 +4,7 @@ import { GameType } from "~/prisma/client/enums"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import { parsePlatform } from "~/server/internal/utils/parseplatform"; +import { getAgeRestrictionFilter } from "~/server/internal/utils/ageRestrictions"; const StoreRead = type({ skip: type("string") @@ -24,8 +25,9 @@ const StoreRead = type({ }); export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserIdACL(h3, ["store:read"]); - if (!userId) throw createError({ statusCode: 403 }); + const user = await aclManager.getUserACL(h3, ["store:read"]); + if (!user) throw createError({ statusCode: 403 }); + const userId = user.id; const query = getQuery(h3); const options = StoreRead(query); @@ -116,10 +118,13 @@ export default defineEventHandler(async (h3) => { * Query */ + const ageFilter = await getAgeRestrictionFilter(userId, user.admin); + const finalFilter: Prisma.GameWhereInput = { ...tagFilter, ...platformFilter, ...companyFilter, + ...ageFilter, type: GameType.Game, }; diff --git a/server/server/internal/auth/oidc/index.ts b/server/server/internal/auth/oidc/index.ts index 72e368193..b20b0d0fe 100644 --- a/server/server/internal/auth/oidc/index.ts +++ b/server/server/internal/auth/oidc/index.ts @@ -407,7 +407,10 @@ export class OIDCManager { }, }); - if (existingAuthMek) return existingAuthMek.user; + if (existingAuthMek) { + await this.syncUserGroups(existingAuthMek.user.id, userinfo.groups); + return existingAuthMek.user; + } const username = userinfo[this.usernameClaim]?.toString(); if (!username) @@ -492,9 +495,35 @@ export class OIDCManager { }, }); + await this.syncUserGroups(created.user.id, userinfo.groups); return created.user; } + private async syncUserGroups( + userId: string, + oidcGroups: string[] | undefined, + ) { + if (!oidcGroups || oidcGroups.length === 0) { + await prisma.user.update({ + where: { id: userId }, + data: { groups: { set: [] } }, + }); + return; + } + + const matchingGroups = await prisma.userGroup.findMany({ + where: { name: { in: oidcGroups } }, + select: { id: true }, + }); + + await prisma.user.update({ + where: { id: userId }, + data: { + groups: { set: matchingGroups.map((g) => ({ id: g.id })) }, + }, + }); + } + /** * Handle OIDC backchannel logout token * @param logout_token diff --git a/server/server/internal/userlibrary/index.ts b/server/server/internal/userlibrary/index.ts index 996ad3fe8..8a60e51ee 100644 --- a/server/server/internal/userlibrary/index.ts +++ b/server/server/internal/userlibrary/index.ts @@ -2,6 +2,7 @@ Handles managing collections */ +import type { Prisma } from "~/prisma/client/client"; import cacheHandler from "../cache"; import prisma from "../db/database"; @@ -45,30 +46,50 @@ class UserLibraryManager { await this.collectionRemove(gameId, userLibraryId, userId); } - async fetchLibrary(userId: string) { + async fetchLibrary( + userId: string, + gameFilter?: Prisma.GameWhereInput, + ) { const userLibraryId = await this.fetchUserLibrary(userId); const userLibrary = await prisma.collection.findUnique({ where: { id: userLibraryId }, - include: { entries: { include: { game: true } } }, + include: { + entries: { + where: gameFilter ? { game: gameFilter } : undefined, + include: { game: true }, + }, + }, }); if (!userLibrary) throw new Error("Failed to load user library"); return userLibrary; } // Will not return the default library - async fetchCollection(collectionId: string) { + async fetchCollection( + collectionId: string, + gameFilter?: Prisma.GameWhereInput, + ) { return await prisma.collection.findUnique({ where: { id: collectionId, isDefault: false }, - include: { entries: { include: { game: true } } }, + include: { + entries: { + where: gameFilter ? { game: gameFilter } : undefined, + include: { game: true }, + }, + }, }); } - async fetchCollections(userId: string) { + async fetchCollections( + userId: string, + gameFilter?: Prisma.GameWhereInput, + ) { await this.fetchUserLibrary(userId); // Ensures user library exists, doesn't have much performance impact due to caching return await prisma.collection.findMany({ where: { userId, isDefault: false }, include: { entries: { + where: gameFilter ? { game: gameFilter } : undefined, include: { game: true, }, diff --git a/server/server/internal/utils/ageRestrictions.ts b/server/server/internal/utils/ageRestrictions.ts new file mode 100644 index 000000000..a8e3c5bb2 --- /dev/null +++ b/server/server/internal/utils/ageRestrictions.ts @@ -0,0 +1,48 @@ +import type { Prisma } from "~/prisma/client/client"; +import prisma from "~/server/internal/db/database"; + +export async function getAgeRestrictionFilter( + userId: string, + isAdmin: boolean, +): Promise { + if (isAdmin) return undefined; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + groups: { + select: { + bannedAgeRatings: { + select: { + organization: true, + rating: true, + }, + }, + }, + }, + }, + }); + + if (!user) return undefined; + + const bannedPairs = user.groups.flatMap((g) => g.bannedAgeRatings); + if (bannedPairs.length === 0) return undefined; + + return { + AND: [ + { ageRatings: { some: {} } }, + { + NOT: { + ageRatings: { + some: { + OR: bannedPairs.map((bp) => ({ + organization: bp.organization, + rating: bp.rating, + })), + }, + }, + }, + }, + ], + }; +} From 0bfed8e528419438e43f5524af6e0593e696f2f3 Mon Sep 17 00:00:00 2001 From: Robert Clabough Date: Wed, 29 Jul 2026 08:53:42 -0700 Subject: [PATCH 2/7] Cleaning up some redundant code --- server/i18n/locales/en_us.json | 3 +- server/pages/admin/users/groups/[id].vue | 40 +++++++++---------- server/server/api/v1/games/[id]/index.get.ts | 19 ++++----- server/server/internal/auth/oidc/index.ts | 5 ++- .../server/internal/utils/ageRestrictions.ts | 13 +++++- 5 files changed, 45 insertions(+), 35 deletions(-) diff --git a/server/i18n/locales/en_us.json b/server/i18n/locales/en_us.json index cfc827122..acddb0bb7 100644 --- a/server/i18n/locales/en_us.json +++ b/server/i18n/locales/en_us.json @@ -869,8 +869,7 @@ "noBannedRatings": "No banned ratings configured.", "unratedNote": "Games without age ratings will also be hidden from users in this group.", "deleteConfirm": "Are you sure you want to delete this group? This action cannot be undone.", - "deleteGroup": "Delete", - "groupsLink": "Groups {arrow}" + "deleteGroup": "Delete" } } }, diff --git a/server/pages/admin/users/groups/[id].vue b/server/pages/admin/users/groups/[id].vue index b6f880374..a44388752 100644 --- a/server/pages/admin/users/groups/[id].vue +++ b/server/pages/admin/users/groups/[id].vue @@ -321,36 +321,34 @@ const removeMember = async (userId: string) => { await fetchGroup(); }; -const saveRatings = async () => { - if (!group.value) return; - await $dropFetch(`/api/v1/admin/groups/${groupId}/ratings`, { - method: "PATCH", - body: { - bannedRatings: group.value.bannedAgeRatings.map((br) => ({ - organization: br.organization, - rating: br.rating, - })), - }, - }); - await fetchGroup(); -}; - const addRating = async () => { if (!group.value || !newRatingOrg.value || !newRatingValue.value) return; - group.value.bannedAgeRatings.push({ - id: "", - organization: newRatingOrg.value, - rating: newRatingValue.value, + const newRatings = [ + ...group.value.bannedAgeRatings.map((br) => ({ + organization: br.organization, + rating: br.rating, + })), + { organization: newRatingOrg.value, rating: newRatingValue.value }, + ]; + await $dropFetch(`/api/v1/admin/groups/${groupId}/ratings`, { + method: "PATCH", + body: { bannedRatings: newRatings }, }); - await saveRatings(); showAddRating.value = false; newRatingOrg.value = ""; newRatingValue.value = ""; + await fetchGroup(); }; const removeRating = async (idx: number) => { if (!group.value) return; - group.value.bannedAgeRatings.splice(idx, 1); - await saveRatings(); + const newRatings = group.value.bannedAgeRatings + .filter((_, i) => i !== idx) + .map((br) => ({ organization: br.organization, rating: br.rating })); + await $dropFetch(`/api/v1/admin/groups/${groupId}/ratings`, { + method: "PATCH", + body: { bannedRatings: newRatings }, + }); + await fetchGroup(); }; diff --git a/server/server/api/v1/games/[id]/index.get.ts b/server/server/api/v1/games/[id]/index.get.ts index bb8ebed56..61293e331 100644 --- a/server/server/api/v1/games/[id]/index.get.ts +++ b/server/server/api/v1/games/[id]/index.get.ts @@ -14,6 +14,16 @@ export default defineEventHandler(async (h3) => { statusMessage: "Missing gameId in route params (somehow...?)", }); + // Check age restrictions before the heavy fetch + const ageFilter = await getAgeRestrictionFilter(user.id, user.admin); + if (ageFilter) { + const allowed = await prisma.game.count({ + where: { id: gameId, ...ageFilter }, + }); + if (allowed === 0) + throw createError({ statusCode: 404, statusMessage: "Game not found" }); + } + const game = await prisma.game.findUnique({ where: { id: gameId }, include: { @@ -53,15 +63,6 @@ export default defineEventHandler(async (h3) => { if (!game) throw createError({ statusCode: 404, statusMessage: "Game not found" }); - const ageFilter = await getAgeRestrictionFilter(user.id, user.admin); - if (ageFilter) { - const allowed = await prisma.game.count({ - where: { id: gameId, ...ageFilter }, - }); - if (allowed === 0) - throw createError({ statusCode: 404, statusMessage: "Game not found" }); - } - const rating = await prisma.gameRating.aggregate({ where: { gameId: game.id, diff --git a/server/server/internal/auth/oidc/index.ts b/server/server/internal/auth/oidc/index.ts index b20b0d0fe..53d451972 100644 --- a/server/server/internal/auth/oidc/index.ts +++ b/server/server/internal/auth/oidc/index.ts @@ -503,7 +503,10 @@ export class OIDCManager { userId: string, oidcGroups: string[] | undefined, ) { - if (!oidcGroups || oidcGroups.length === 0) { + // If the IdP didn't include the groups claim, don't touch membership + if (oidcGroups === undefined) return; + + if (oidcGroups.length === 0) { await prisma.user.update({ where: { id: userId }, data: { groups: { set: [] } }, diff --git a/server/server/internal/utils/ageRestrictions.ts b/server/server/internal/utils/ageRestrictions.ts index a8e3c5bb2..9258efc4b 100644 --- a/server/server/internal/utils/ageRestrictions.ts +++ b/server/server/internal/utils/ageRestrictions.ts @@ -25,8 +25,17 @@ export async function getAgeRestrictionFilter( if (!user) return undefined; - const bannedPairs = user.groups.flatMap((g) => g.bannedAgeRatings); - if (bannedPairs.length === 0) return undefined; + const allBanned = user.groups.flatMap((g) => g.bannedAgeRatings); + if (allBanned.length === 0) return undefined; + + // Deduplicate across groups + const seen = new Set(); + const bannedPairs = allBanned.filter((bp) => { + const key = `${bp.organization}:${bp.rating}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); return { AND: [ From 67f52bc89b03bbc1e463ccc934bbc5f88472a794 Mon Sep 17 00:00:00 2001 From: Robert Clabough Date: Wed, 29 Jul 2026 09:11:27 -0700 Subject: [PATCH 3/7] hastily made translations --- server/i18n/locales/de.json | 22 +++++++++++++++++++++- server/i18n/locales/en_pirate.json | 22 +++++++++++++++++++++- server/i18n/locales/fr.json | 22 +++++++++++++++++++++- server/i18n/locales/pl.json | 22 +++++++++++++++++++++- 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/server/i18n/locales/de.json b/server/i18n/locales/de.json index 7da8e7335..a27b27ec6 100644 --- a/server/i18n/locales/de.json +++ b/server/i18n/locales/de.json @@ -732,7 +732,27 @@ "userInvitation": "Benutzereinladung" }, "srEditLabel": "Bearbeiten", - "usernameHeader": "Nutzername" + "usernameHeader": "Nutzername", + "groups": { + "title": "Benutzergruppen", + "description": "Benutzergruppen und Altersbeschränkungen verwalten.", + "createGroup": "Gruppe erstellen", + "name": "Name", + "descriptionField": "Beschreibung", + "details": "Details", + "members": "Mitglieder", + "addMember": "Benutzer auswählen...", + "removeMember": "Entfernen", + "noMembers": "Keine Mitglieder in dieser Gruppe.", + "oidcManagedNote": "Die Gruppenmitgliedschaft wird von Ihrem OIDC-Anbieter verwaltet. Mitglieder werden bei der Anmeldung automatisch synchronisiert. Um die Gruppenmitgliedschaft zu ändern, aktualisieren Sie die Gruppenzuweisungen Ihres Identitätsanbieters.", + "bannedRatings": "Gesperrte Alterseinstufungen", + "addBannedRating": "Gesperrte Einstufung hinzufügen", + "removeBannedRating": "Entfernen", + "noBannedRatings": "Keine gesperrten Einstufungen konfiguriert.", + "unratedNote": "Spiele ohne Alterseinstufung werden für Benutzer in dieser Gruppe ebenfalls ausgeblendet.", + "deleteConfirm": "Sind Sie sicher, dass Sie diese Gruppe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteGroup": "Löschen" + } } }, "welcome": "Deutsche, willkommen!" diff --git a/server/i18n/locales/en_pirate.json b/server/i18n/locales/en_pirate.json index 282ba72ae..7facfe628 100644 --- a/server/i18n/locales/en_pirate.json +++ b/server/i18n/locales/en_pirate.json @@ -531,7 +531,27 @@ "userInvitation": "Crewman's Invitation" }, "srEditLabel": "Amend", - "usernameHeader": "Crew Name" + "usernameHeader": "Crew Name", + "groups": { + "title": "Crew Groups", + "description": "Manage yer crew groups and age restrictions on the plunder.", + "createGroup": "Form a Crew", + "name": "Name", + "descriptionField": "Description", + "details": "Details", + "members": "Crew Members", + "addMember": "Pick a scallywag...", + "removeMember": "Cast overboard", + "noMembers": "No souls in this crew.", + "oidcManagedNote": "Crew membership be managed by yer OIDC harbourmaster. Members be synced when they come aboard. To change crew membership, update yer identity provider's assignments.", + "bannedRatings": "Forbidden Ratings", + "addBannedRating": "Add forbidden rating", + "removeBannedRating": "Remove", + "noBannedRatings": "No forbidden ratings set, captain.", + "unratedNote": "Unrated plunder will also be hidden from scallywags in this crew.", + "deleteConfirm": "Be ye sure ye want to disband this crew? There be no turnin' back!", + "deleteGroup": "Disband" + } } }, "welcome": "Ahoy, Welcome!" diff --git a/server/i18n/locales/fr.json b/server/i18n/locales/fr.json index 82c244a3e..35849d54a 100644 --- a/server/i18n/locales/fr.json +++ b/server/i18n/locales/fr.json @@ -732,7 +732,27 @@ "userInvitation": "Invitation utilisateur" }, "srEditLabel": "Éditer", - "usernameHeader": "Nom d'utilisateur" + "usernameHeader": "Nom d'utilisateur", + "groups": { + "title": "Groupes d'utilisateurs", + "description": "Gérer les groupes d'utilisateurs et les restrictions d'âge.", + "createGroup": "Créer un groupe", + "name": "Nom", + "descriptionField": "Description", + "details": "Détails", + "members": "Membres", + "addMember": "Sélectionner un utilisateur...", + "removeMember": "Retirer", + "noMembers": "Aucun membre dans ce groupe.", + "oidcManagedNote": "L'appartenance aux groupes est gérée par votre fournisseur OIDC. Les membres sont automatiquement synchronisés lors de la connexion. Pour modifier l'appartenance aux groupes, mettez à jour les affectations de groupes de votre fournisseur d'identité.", + "bannedRatings": "Classifications interdites", + "addBannedRating": "Ajouter une classification interdite", + "removeBannedRating": "Retirer", + "noBannedRatings": "Aucune classification interdite configurée.", + "unratedNote": "Les jeux sans classification d'âge seront également masqués pour les utilisateurs de ce groupe.", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer ce groupe ? Cette action est irréversible.", + "deleteGroup": "Supprimer" + } } }, "welcome": "Américain, bienvenue !" diff --git a/server/i18n/locales/pl.json b/server/i18n/locales/pl.json index f532803f7..5f984cb9f 100644 --- a/server/i18n/locales/pl.json +++ b/server/i18n/locales/pl.json @@ -708,7 +708,27 @@ "userInvitation": "Zaproszenie użytkownika" }, "srEditLabel": "Edytuj", - "usernameHeader": "Nazwa Użytkownika" + "usernameHeader": "Nazwa Użytkownika", + "groups": { + "title": "Grupy użytkowników", + "description": "Zarządzaj grupami użytkowników i ograniczeniami wiekowymi.", + "createGroup": "Utwórz grupę", + "name": "Nazwa", + "descriptionField": "Opis", + "details": "Szczegóły", + "members": "Członkowie", + "addMember": "Wybierz użytkownika...", + "removeMember": "Usuń", + "noMembers": "Brak członków w tej grupie.", + "oidcManagedNote": "Członkostwo w grupach jest zarządzane przez dostawcę OIDC. Członkowie są automatycznie synchronizowani podczas logowania. Aby zmienić członkostwo w grupach, zaktualizuj przypisania grup u dostawcy tożsamości.", + "bannedRatings": "Zablokowane kategorie wiekowe", + "addBannedRating": "Dodaj zablokowaną kategorię", + "removeBannedRating": "Usuń", + "noBannedRatings": "Brak skonfigurowanych zablokowanych kategorii.", + "unratedNote": "Gry bez kategorii wiekowej będą również ukryte przed użytkownikami w tej grupie.", + "deleteConfirm": "Czy na pewno chcesz usunąć tę grupę? Tej akcji nie można cofnąć.", + "deleteGroup": "Usuń" + } } }, "welcome": "Polaku, Witaj!" From 5e1f4777102ce86110cf0a9357a42a7892b174e1 Mon Sep 17 00:00:00 2001 From: Robert Clabough Date: Wed, 29 Jul 2026 09:14:51 -0700 Subject: [PATCH 4/7] Linter issues --- server/pages/admin/users/groups/[id].vue | 9 +++++---- server/pages/admin/users/index.vue | 4 +++- server/server/api/v1/admin/groups/[id]/index.patch.ts | 1 + server/server/api/v1/admin/groups/[id]/members.patch.ts | 1 + server/server/internal/auth/oidc/index.ts | 2 ++ 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/server/pages/admin/users/groups/[id].vue b/server/pages/admin/users/groups/[id].vue index a44388752..f21c8ffa6 100644 --- a/server/pages/admin/users/groups/[id].vue +++ b/server/pages/admin/users/groups/[id].vue @@ -67,8 +67,10 @@ :key="member.id" class="flex items-center justify-between rounded-md bg-zinc-800/50 px-3 py-2" > + {{ member.displayName }} + ({{ member.username }})