diff --git a/server/i18n/locales/de.json b/server/i18n/locales/de.json index 7da8e7335..9d9a6abfb 100644 --- a/server/i18n/locales/de.json +++ b/server/i18n/locales/de.json @@ -699,6 +699,26 @@ "description": "Verwalte Benutzer auf deiner Drop-Instanz und konfiguriere deine Authentifizierungsmethode.", "displayNameHeader": "Anzeigename", "emailHeader": "E-Mail", + "groups": { + "addBannedRating": "Gesperrte Einstufung hinzufügen", + "addMember": "Benutzer auswählen...", + "bannedRatings": "Gesperrte Alterseinstufungen", + "createGroup": "Gruppe erstellen", + "deleteConfirm": "Sind Sie sicher, dass Sie diese Gruppe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteGroup": "Löschen", + "description": "Benutzergruppen und Altersbeschränkungen verwalten.", + "descriptionField": "Beschreibung", + "details": "Details", + "members": "Mitglieder", + "name": "Name", + "noBannedRatings": "Keine gesperrten Einstufungen konfiguriert.", + "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.", + "removeBannedRating": "Entfernen", + "removeMember": "Entfernen", + "title": "Benutzergruppen", + "unratedNote": "Spiele ohne Alterseinstufung werden für Benutzer in dieser Gruppe ebenfalls ausgeblendet." + }, "normalUserLabel": "Normaler Benutzer", "simple": { "adminInvitation": "Admin Einladung", diff --git a/server/i18n/locales/en_pirate.json b/server/i18n/locales/en_pirate.json index 282ba72ae..2201b510a 100644 --- a/server/i18n/locales/en_pirate.json +++ b/server/i18n/locales/en_pirate.json @@ -498,6 +498,26 @@ "description": "Manage the crew on yer Drop vessel, and set yer passage methods, savvy?", "displayNameHeader": "Scallywag Name", "emailHeader": "Salty Mail", + "groups": { + "addBannedRating": "Add forbidden rating", + "addMember": "Pick a scallywag...", + "bannedRatings": "Forbidden Ratings", + "createGroup": "Form a Crew", + "deleteConfirm": "Be ye sure ye want to disband this crew? There be no turnin' back!", + "deleteGroup": "Disband", + "description": "Manage yer crew groups and age restrictions on the plunder.", + "descriptionField": "Description", + "details": "Details", + "members": "Crew Members", + "name": "Name", + "noBannedRatings": "No forbidden ratings set, captain.", + "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.", + "removeBannedRating": "Remove", + "removeMember": "Cast overboard", + "title": "Crew Groups", + "unratedNote": "Unrated plunder will also be hidden from scallywags in this crew." + }, "normalUserLabel": "Common crewman", "simple": { "adminInvitation": "Cap'n's Invitation", diff --git a/server/i18n/locales/en_us.json b/server/i18n/locales/en_us.json index e3f0becf0..3352f2a0a 100644 --- a/server/i18n/locales/en_us.json +++ b/server/i18n/locales/en_us.json @@ -817,6 +817,26 @@ "description": "Manage the users on your Drop instance, and configure your authentication methods.", "displayNameHeader": "Display Name", "emailHeader": "Email", + "groups": { + "addBannedRating": "Add banned rating", + "addMember": "Select a user...", + "bannedRatings": "Banned Ratings", + "createGroup": "Create Group", + "deleteConfirm": "Are you sure you want to delete this group? This action cannot be undone.", + "deleteGroup": "Delete", + "description": "Manage user groups and age rating restrictions.", + "descriptionField": "Description", + "details": "Details", + "members": "Members", + "name": "Name", + "noBannedRatings": "No banned ratings configured.", + "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.", + "removeBannedRating": "Remove", + "removeMember": "Remove", + "title": "User Groups", + "unratedNote": "Games without age ratings will also be hidden from users in this group." + }, "normalUserLabel": "Normal user", "simple": { "adminInvitation": "Admin invitation", diff --git a/server/i18n/locales/fr.json b/server/i18n/locales/fr.json index 82c244a3e..4fc8f23cf 100644 --- a/server/i18n/locales/fr.json +++ b/server/i18n/locales/fr.json @@ -699,6 +699,26 @@ "description": "Gérer les utilisateurs sur votre instance Drop, et configurer vos méthodes d'authentification.", "displayNameHeader": "Nom d'affichage", "emailHeader": "Email", + "groups": { + "addBannedRating": "Ajouter une classification interdite", + "addMember": "Sélectionner un utilisateur...", + "bannedRatings": "Classifications interdites", + "createGroup": "Créer un groupe", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer ce groupe ? Cette action est irréversible.", + "deleteGroup": "Supprimer", + "description": "Gérer les groupes d'utilisateurs et les restrictions d'âge.", + "descriptionField": "Description", + "details": "Détails", + "members": "Membres", + "name": "Nom", + "noBannedRatings": "Aucune classification interdite configurée.", + "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é.", + "removeBannedRating": "Retirer", + "removeMember": "Retirer", + "title": "Groupes d'utilisateurs", + "unratedNote": "Les jeux sans classification d'âge seront également masqués pour les utilisateurs de ce groupe." + }, "normalUserLabel": "Utilisateur normal", "simple": { "adminInvitation": "Invitation adminstrateur", diff --git a/server/i18n/locales/pl.json b/server/i18n/locales/pl.json index f532803f7..2b5c11ff6 100644 --- a/server/i18n/locales/pl.json +++ b/server/i18n/locales/pl.json @@ -675,6 +675,26 @@ "description": "Zarządzaj użytkownikami na twojej instancji Drop, i skonfiguruj swoje metody uwierzytelniania.", "displayNameHeader": "Nazwa Wyświetlana", "emailHeader": "Email", + "groups": { + "addBannedRating": "Dodaj zablokowaną kategorię", + "addMember": "Wybierz użytkownika...", + "bannedRatings": "Zablokowane kategorie wiekowe", + "createGroup": "Utwórz grupę", + "deleteConfirm": "Czy na pewno chcesz usunąć tę grupę? Tej akcji nie można cofnąć.", + "deleteGroup": "Usuń", + "description": "Zarządzaj grupami użytkowników i ograniczeniami wiekowymi.", + "descriptionField": "Opis", + "details": "Szczegóły", + "members": "Członkowie", + "name": "Nazwa", + "noBannedRatings": "Brak skonfigurowanych zablokowanych kategorii.", + "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.", + "removeBannedRating": "Usuń", + "removeMember": "Usuń", + "title": "Grupy użytkowników", + "unratedNote": "Gry bez kategorii wiekowej będą również ukryte przed użytkownikami w tej grupie." + }, "normalUserLabel": "Normalny użytkownik", "simple": { "adminInvitation": "Zaproszenie administratora", diff --git a/server/pages/admin/users/groups/[id].vue b/server/pages/admin/users/groups/[id].vue new file mode 100644 index 000000000..b9878af53 --- /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..862ffa1f4 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,204 @@
+ + +
+
+
+

+ {{ $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") }} + + + {{ $t("users.admin.srEditLabel") }} + +
+ {{ 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 +354,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..80e9311d8 --- /dev/null +++ b/server/server/api/v1/admin/groups/[id]/index.delete.ts @@ -0,0 +1,19 @@ +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" }); + + // SAFETY: existence verified above via findUnique + // 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..f273259f0 --- /dev/null +++ b/server/server/api/v1/admin/groups/[id]/index.patch.ts @@ -0,0 +1,42 @@ +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", + }); + + // SAFETY: existence verified above via findUnique + // eslint-disable-next-line drop/no-prisma-delete + 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..c00d2bc1e --- /dev/null +++ b/server/server/api/v1/admin/groups/[id]/members.patch.ts @@ -0,0 +1,44 @@ +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" }); + + // SAFETY: existence verified above via findUnique + // eslint-disable-next-line drop/no-prisma-delete + await prisma.userGroup.update({ + where: { id }, + data: { + users: { set: body.userIds.map((uid) => ({ id: uid })) }, + }, + }); + + const updated = await prisma.userGroup.findUnique({ + where: { id }, + include: { + users: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + }); + + return updated; +}); 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..9f7529944 --- /dev/null +++ b/server/server/api/v1/admin/groups/[id]/ratings.patch.ts @@ -0,0 +1,65 @@ +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, + })), + }), + ]); + + const ratings = await prisma.bannedAgeRating.findMany({ + where: { userGroupId: id }, + }); + + return ratings; +}); 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..9c7a45c15 --- /dev/null +++ b/server/server/api/v1/admin/groups/index.post.ts @@ -0,0 +1,34 @@ +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/collection/default/entry.post.ts b/server/server/api/v1/client/collection/default/entry.post.ts index 5e7f60389..844b8c1c2 100644 --- a/server/server/api/v1/client/collection/default/entry.post.ts +++ b/server/server/api/v1/client/collection/default/entry.post.ts @@ -1,5 +1,7 @@ import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; import userLibraryManager from "~/server/internal/userlibrary"; +import { getAgeRestrictionFilter } from "~/server/internal/utils/ageRestrictions"; export default defineClientEventHandler(async (h3, { fetchUser }) => { const user = await fetchUser(); @@ -9,6 +11,15 @@ export default defineClientEventHandler(async (h3, { fetchUser }) => { if (!gameId) throw createError({ statusCode: 400, statusMessage: "Game ID required" }); + 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" }); + } + // Add the game to the default collection await userLibraryManager.libraryAdd(gameId, user.id); return {}; 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]/entry.post.ts b/server/server/api/v1/collection/[id]/entry.post.ts index d6a2394fd..7d84ae8fd 100644 --- a/server/server/api/v1/collection/[id]/entry.post.ts +++ b/server/server/api/v1/collection/[id]/entry.post.ts @@ -1,9 +1,11 @@ import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; 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:add"]); - if (!userId) + const user = await aclManager.getUserACL(h3, ["collections:add"]); + if (!user) throw createError({ statusCode: 403, }); @@ -20,5 +22,16 @@ export default defineEventHandler(async (h3) => { if (!gameId) throw createError({ statusCode: 400, statusMessage: "Game ID required" }); - return await userLibraryManager.collectionAdd(gameId, id, userId); + 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 result = await userLibraryManager.collectionAdd(gameId, id, user.id); + + return result; }); 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/entry.post.ts b/server/server/api/v1/collection/default/entry.post.ts index 4c8b8fde5..02970394b 100644 --- a/server/server/api/v1/collection/default/entry.post.ts +++ b/server/server/api/v1/collection/default/entry.post.ts @@ -1,9 +1,11 @@ import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; import userLibraryManager from "~/server/internal/userlibrary"; +import { getAgeRestrictionFilter } from "~/server/internal/utils/ageRestrictions"; export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserIdACL(h3, ["library:add"]); - if (!userId) + const user = await aclManager.getUserACL(h3, ["library:add"]); + if (!user) throw createError({ statusCode: 403, statusMessage: "Requires authentication", @@ -14,7 +16,16 @@ export default defineEventHandler(async (h3) => { if (!gameId) throw createError({ statusCode: 400, statusMessage: "Game ID required" }); + 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" }); + } + // Add the game to the default collection - await userLibraryManager.libraryAdd(gameId, userId); + await userLibraryManager.libraryAdd(gameId, user.id); return {}; }); 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..61293e331 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) @@ -13,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: { 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..014df02a3 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,38 @@ export class OIDCManager { }, }); + await this.syncUserGroups(created.user.id, userinfo.groups); return created.user; } + private async syncUserGroups( + userId: string, + oidcGroups: string[] | undefined, + ) { + // If the IdP didn't include the groups claim, don't touch membership + if (oidcGroups === undefined) return; + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return; + + const groupsToSet = + oidcGroups.length === 0 + ? [] + : ( + await prisma.userGroup.findMany({ + where: { name: { in: oidcGroups } }, + select: { id: true }, + }) + ).map((g) => ({ id: g.id })); + + // SAFETY: existence verified above via findUnique + // eslint-disable-next-line drop/no-prisma-delete + await prisma.user.update({ + where: { id: userId }, + data: { groups: { set: groupsToSet } }, + }); + } + /** * 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..6991b8a77 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,44 @@ 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..9258efc4b --- /dev/null +++ b/server/server/internal/utils/ageRestrictions.ts @@ -0,0 +1,57 @@ +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 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: [ + { ageRatings: { some: {} } }, + { + NOT: { + ageRatings: { + some: { + OR: bannedPairs.map((bp) => ({ + organization: bp.organization, + rating: bp.rating, + })), + }, + }, + }, + }, + ], + }; +}