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 @@
+
+
+
+ {{ group.name }}
+
+
+ {{ group.description }}
+
+
+
+
+
+ {{ $t("users.admin.groups.details") }}
+
+
+
+
+ {{ $t("users.admin.groups.name") }}
+
+
+
+
+
+ {{ $t("users.admin.groups.descriptionField") }}
+
+
+
+
+ {{ $t("common.save") }}
+
+
+
+
+
+
+
+ {{ $t("users.admin.groups.members") }}
+
+
+
+
+
+ {{ $t("users.admin.groups.oidcManagedNote") }}
+
+
+
+
+ {{ $t("users.admin.groups.noMembers") }}
+
+
+
+
+
+ {{ member.displayName }}
+
+ ({{ member.username }})
+
+
+ {{ $t("users.admin.groups.removeMember") }}
+
+
+
+
+
+
+
+
+ {{ $t("users.admin.groups.addMember") }}
+
+
+
+ {{ user.displayName }} ({{ user.username }})
+
+
+
+ {{ $t("add") }}
+
+
+
+
+
+
+
+ {{ $t("users.admin.groups.bannedRatings") }}
+
+
+ {{ $t("users.admin.groups.unratedNote") }}
+
+
+
+ {{ $t("users.admin.groups.noBannedRatings") }}
+
+
+
+
+
+
+ {{ br.organization }}: {{ br.rating }}
+
+
+ {{ $t("users.admin.groups.removeBannedRating") }}
+
+
+
+
+
+
+
+ {{ org }}
+
+
+
+
+ {{ r }}
+
+
+
+ {{ $t("add") }}
+
+
+ {{ $t("cancel") }}
+
+
+
+ {{ $t("users.admin.groups.addBannedRating") }}
+
+
+
+
+
+
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.createGroup") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $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.deleteGroup") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("users.admin.groups.createGroup") }}
+
+
+
+
+ {{ $t("users.admin.groups.name") }}
+
+
+
+
+
+ {{ $t("users.admin.groups.descriptionField") }}
+
+
+
+
+
+ {{ $t("cancel") }}
+
+
+ {{ $t("users.admin.groups.createGroup") }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t("users.admin.groups.deleteGroup") }}
+
+
+ {{ $t("users.admin.groups.deleteConfirm") }}
+
+
+
+ {{ $t("cancel") }}
+
+
+ {{ $t("users.admin.groups.deleteGroup") }}
+
+
+
+
@@ -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,
+ })),
+ },
+ },
+ },
+ },
+ ],
+ };
+}