From 820a77bafc7a55414cf59b37b9c1ee79457d1f22 Mon Sep 17 00:00:00 2001 From: Axel Rindle Date: Thu, 12 Feb 2026 17:23:14 +0100 Subject: [PATCH 01/25] feat: implement access request for Gliederung --- .../gliederung-access-request-decision.mjml | 18 ++ .../email/gliederung-access-request-info.mjml | 18 ++ .../migration.sql | 3 + .../migration.sql | 2 + apps/api/prisma/schema/Gliederung.prisma | 1 + .../prisma/schema/GliederungToAccount.prisma | 3 + apps/api/src/config.ts | 12 +- apps/api/src/context.ts | 36 +++- .../access/access.requestGliederungCreate.ts | 75 ++++++++ .../access/access.requestGliederungPatch.ts | 113 +++++++++++ .../access.requestGliederungValidate.ts | 38 ++++ .../services/access/access.requestListAll.ts | 84 +++++++++ .../services/access/access.requestListOwn.ts | 29 +++ apps/api/src/services/access/access.router.ts | 14 ++ apps/api/src/services/index.ts | 2 + .../api/src/util/getGliederungRequireAdmin.ts | 2 + apps/api/src/util/zod.ts | 8 + .../LayoutComponents/Sidebar/Sidebar.vue | 15 ++ .../gliederung/FormGliederungGeneral.vue | 11 ++ apps/frontend/src/router/index.ts | 4 + .../Anfragen/RequestGliederungAccess.vue | 114 ++++++++++++ apps/frontend/src/views/Anfragen/routes.ts | 18 ++ .../src/views/Confirm/GliederungAccess.vue | 176 ++++++++++++++++++ apps/frontend/src/views/Confirm/routes.ts | 21 +++ .../GliederungAccessRequests.vue | 111 +++++++++++ .../views/Verwaltung/AccessControl/routes.ts | 24 +++ .../Gliederungen/GliederungList.vue | 5 + apps/frontend/src/views/Verwaltung/routes.ts | 2 + 28 files changed, 942 insertions(+), 17 deletions(-) create mode 100644 apps/api/email/gliederung-access-request-decision.mjml create mode 100644 apps/api/email/gliederung-access-request-info.mjml create mode 100644 apps/api/prisma/migrations/20260210201554_gliederung_access_request/migration.sql create mode 100644 apps/api/prisma/migrations/20260210201607_gliederung_email/migration.sql create mode 100644 apps/api/src/services/access/access.requestGliederungCreate.ts create mode 100644 apps/api/src/services/access/access.requestGliederungPatch.ts create mode 100644 apps/api/src/services/access/access.requestGliederungValidate.ts create mode 100644 apps/api/src/services/access/access.requestListAll.ts create mode 100644 apps/api/src/services/access/access.requestListOwn.ts create mode 100644 apps/api/src/services/access/access.router.ts create mode 100644 apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue create mode 100644 apps/frontend/src/views/Anfragen/routes.ts create mode 100644 apps/frontend/src/views/Confirm/GliederungAccess.vue create mode 100644 apps/frontend/src/views/Confirm/routes.ts create mode 100644 apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue create mode 100644 apps/frontend/src/views/Verwaltung/AccessControl/routes.ts diff --git a/apps/api/email/gliederung-access-request-decision.mjml b/apps/api/email/gliederung-access-request-decision.mjml new file mode 100644 index 00000000..0cbddcaa --- /dev/null +++ b/apps/api/email/gliederung-access-request-decision.mjml @@ -0,0 +1,18 @@ +{{#> layout }} + + + {{ name }} hat soeben einen Antrag auf Zugriff auf die Gliederung {{ gliederung }} gestellt. Bitte prüft den Antrag + und bestätigt diesen, damit {{ name }} Zugriff auf die Ausschreibungen eurer Gliederung erhält. + + + + Nutzt für die Bestätigung oder Ablehnung des Antrags bitte folgenden Link: {{ confirmLink }} + + +{{/layout}} diff --git a/apps/api/email/gliederung-access-request-info.mjml b/apps/api/email/gliederung-access-request-info.mjml new file mode 100644 index 00000000..0cbddcaa --- /dev/null +++ b/apps/api/email/gliederung-access-request-info.mjml @@ -0,0 +1,18 @@ +{{#> layout }} + + + {{ name }} hat soeben einen Antrag auf Zugriff auf die Gliederung {{ gliederung }} gestellt. Bitte prüft den Antrag + und bestätigt diesen, damit {{ name }} Zugriff auf die Ausschreibungen eurer Gliederung erhält. + + + + Nutzt für die Bestätigung oder Ablehnung des Antrags bitte folgenden Link: {{ confirmLink }} + + +{{/layout}} diff --git a/apps/api/prisma/migrations/20260210201554_gliederung_access_request/migration.sql b/apps/api/prisma/migrations/20260210201554_gliederung_access_request/migration.sql new file mode 100644 index 00000000..dc4b15c8 --- /dev/null +++ b/apps/api/prisma/migrations/20260210201554_gliederung_access_request/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "GliederungToAccount" ADD COLUMN "confirmByGliederungToken" TEXT, +ADD COLUMN "confirmedByGliederung" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/migrations/20260210201607_gliederung_email/migration.sql b/apps/api/prisma/migrations/20260210201607_gliederung_email/migration.sql new file mode 100644 index 00000000..a935eb1f --- /dev/null +++ b/apps/api/prisma/migrations/20260210201607_gliederung_email/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Gliederung" ADD COLUMN "email" TEXT; diff --git a/apps/api/prisma/schema/Gliederung.prisma b/apps/api/prisma/schema/Gliederung.prisma index 80f8dfaf..f4325898 100644 --- a/apps/api/prisma/schema/Gliederung.prisma +++ b/apps/api/prisma/schema/Gliederung.prisma @@ -2,6 +2,7 @@ model Gliederung { id String @id @default(uuid(7)) name String edv String @unique + email String? unterveranstaltungen Unterveranstaltung[] personen Person[] GliederungToAccount GliederungToAccount[] diff --git a/apps/api/prisma/schema/GliederungToAccount.prisma b/apps/api/prisma/schema/GliederungToAccount.prisma index b6fcc185..4df37e1d 100644 --- a/apps/api/prisma/schema/GliederungToAccount.prisma +++ b/apps/api/prisma/schema/GliederungToAccount.prisma @@ -12,5 +12,8 @@ model GliederungToAccount { account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) role GliederungAccountRole + confirmByGliederungToken String? + confirmedByGliederung Boolean @default(false) + @@unique([gliederungId, accountId]) } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 4fdff314..799495fd 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -5,6 +5,7 @@ import type { StringValue } from '@codeanker/authentication' import { FileProvider } from '@prisma/client' import config from 'config' import { z } from 'zod' +import { zEmptyStringAsUndefined } from './util/zod.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -37,16 +38,7 @@ export const configSchema = z.strictObject({ dlrg: z.strictObject({ issuer: z.string().url(), clientId: z.string(), - clientSecret: z - .string() - .optional() - .transform((v) => { - const trimmed = v?.trim() - if (trimmed === undefined || trimmed.length === 0) { - return undefined - } - return trimmed - }), + clientSecret: z.string().optional().transform(zEmptyStringAsUndefined), allowInsecure: z.coerce.boolean(), }), }), diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index 507acc92..bd08527e 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -1,4 +1,3 @@ -import type { Account } from '@prisma/client' import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' import { getEntityIdFromHeader } from './authentication.js' import { logger } from './logger.js' @@ -12,6 +11,33 @@ function getAuthorizationHeader(headers: FetchCreateContextFnOptions['req']['hea } } +function getAccountById(accountId: string) { + return prisma.account.findFirst({ + where: { + id: accountId, + }, + select: { + id: true, + activatedAt: true, + email: true, + role: true, + status: true, + GliederungToAccount: { + select: { + confirmedByGliederung: true, + role: true, + }, + }, + person: { + select: { + firstname: true, + lastname: true, + }, + }, + }, + }) +} + export async function createContext({ req }: FetchCreateContextFnOptions): Promise { try { const authorization = getAuthorizationHeader(req.headers) @@ -26,11 +52,7 @@ export async function createContext({ req }: FetchCreateContextFnOptions): Promi } } - const account = await prisma.account.findFirst({ - where: { - id: accountId, - }, - }) + const account = await getAccountById(accountId) if (account === null) { return { @@ -64,7 +86,7 @@ type AuthContext = | { authenticated: true accountId: string - account: Account + account: Awaited> } export type Context = AuthContext diff --git a/apps/api/src/services/access/access.requestGliederungCreate.ts b/apps/api/src/services/access/access.requestGliederungCreate.ts new file mode 100644 index 00000000..1396f5f2 --- /dev/null +++ b/apps/api/src/services/access/access.requestGliederungCreate.ts @@ -0,0 +1,75 @@ +import { TRPCError } from '@trpc/server' +import * as uuid from 'uuid' +import { z } from 'zod' +import prisma from '../../prisma.js' +import { defineProtectedMutateProcedure } from '../../types/defineProcedure.js' +import { sendMail } from '../../util/mail.js' +import config from '../../config.js' + +export const requestGliederungAccessCreateProcedure = defineProtectedMutateProcedure({ + key: 'requestGliederungAdminCreate', + roleIds: ['USER'], + inputSchema: z.strictObject({ + gliederungId: z.string().uuid(), + }), + handler: async ({ ctx, input }) => { + const existing = await prisma.gliederungToAccount.findFirst({ + where: { + accountId: ctx.accountId, + gliederungId: input.gliederungId, + confirmedByGliederung: false, + }, + }) + if (existing !== null) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Es gibt bereits eine ausstehende Anfrage für diese Gliederung.', + }) + } + + const gliederung = await prisma.gliederung.findUniqueOrThrow({ + where: { + id: input.gliederungId, + }, + select: { + name: true, + email: true, + }, + }) + + if (!gliederung.email) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Die Gliederung ${gliederung.name} hat keine Kontaktadresse hinterlegt!`, + }) + } + + const confirmByGliederungToken = uuid.v7() + + await prisma.$transaction(async (tx) => { + await tx.gliederungToAccount.create({ + data: { + gliederungId: input.gliederungId, + accountId: ctx.accountId, + role: 'DELEGATIONSLEITER', + confirmedByGliederung: false, + confirmByGliederungToken, + }, + }) + + await sendMail({ + to: `${gliederung.email}`, + subject: 'Zugriffsanfrage auf deine Gliederung', + template: 'gliederung-access-request-info', + categories: ['access', 'gliederung'], + variables: { + hostname: 'brahmsee.digital', + gliederung: gliederung.name, + name: gliederung.name, + veranstaltung: 'Zugriffsanfrage', + confirmLink: `${config.clientUrl}/confirm/gliederung-access/${confirmByGliederungToken}`, + }, + }) + }) + }, +}) diff --git a/apps/api/src/services/access/access.requestGliederungPatch.ts b/apps/api/src/services/access/access.requestGliederungPatch.ts new file mode 100644 index 00000000..3b703386 --- /dev/null +++ b/apps/api/src/services/access/access.requestGliederungPatch.ts @@ -0,0 +1,113 @@ +import { z } from 'zod' +import { defineProtectedMutateProcedure } from '../../types/defineProcedure.js' +import prisma from '../../prisma.js' +import logActivity from '../../util/activity.js' +import { TRPCError } from '@trpc/server' +import { sendMail } from '../../util/mail.js' + +export const requestGliederungAdminDecideProcedure = defineProtectedMutateProcedure({ + key: 'requestGliederungAdminDecide', + roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'], + inputSchema: z.strictObject({ + token: z.string().uuid().optional(), + requestId: z.number().int(), + decision: z.boolean(), + }), + handler: async ({ ctx, input: { token, requestId, decision } }) => { + if (ctx.account?.role === 'GLIEDERUNG_ADMIN' && token === undefined) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'token is required', + }) + } + + const request = await prisma.gliederungToAccount.findUnique({ + where: { + id: requestId, + confirmByGliederungToken: ctx.account?.role === 'ADMIN' ? undefined : token, + }, + select: { + id: true, + accountId: true, + account: { + select: { + email: true, + }, + }, + gliederung: { + select: { + name: true, + }, + }, + }, + }) + if (request === null) { + throw new TRPCError({ + code: 'NOT_FOUND', + }) + } + + if (!decision) { + await prisma.gliederungToAccount.delete({ + where: { + id: requestId, + }, + }) + + await logActivity({ + type: 'DELETE', + subjectType: 'gliederungtoaccount', + subjectId: `${request.id}`, + description: `Eine Zugriffsanfrage auf die Gliederung ${request.gliederung.name} wurde abgelehnt.`, + causerId: ctx.accountId, + }) + } else { + await prisma.$transaction(async (tx) => { + const { accountId } = await tx.gliederungToAccount.update({ + where: { + id: requestId, + }, + data: { + confirmByGliederungToken: null, + confirmedByGliederung: true, + }, + select: { + accountId: true, + }, + }) + await tx.account.update({ + where: { + id: accountId, + }, + data: { + role: 'GLIEDERUNG_ADMIN', + }, + }) + }) + + await logActivity({ + type: 'UPDATE', + subjectType: 'account', + subjectId: request.accountId, + description: `Account ${request.account.email} ist jetzt Gliederungsadmin der Gliederung ${request.gliederung.name}`, + causerId: ctx.accountId, + }) + } + + await sendMail({ + to: request.account.email, + categories: ['access', 'gliederung'], + template: 'gliederung-access-request-decision', + subject: `Zugriffsanfrage auf deine Gliederung ${decision ? 'bestätigt' : 'abgelehnt'}`, + variables: { + hostname: 'brahmsee.digital', + gliederung: request.gliederung.name, + name: `${ctx.account?.person.firstname} ${ctx.account?.person.lastname}`, + veranstaltung: 'Zugriffsanfrage', + decision, + }, + }) + + return decision + }, +}) diff --git a/apps/api/src/services/access/access.requestGliederungValidate.ts b/apps/api/src/services/access/access.requestGliederungValidate.ts new file mode 100644 index 00000000..c8cd7ef6 --- /dev/null +++ b/apps/api/src/services/access/access.requestGliederungValidate.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import { definePublicQueryProcedure } from '../../types/defineProcedure.js' +import prisma from '../../prisma.js' + +export const requestGliederungAccessValidateProcedure = definePublicQueryProcedure({ + key: 'requestGliederungAccessValidate', + inputSchema: z.strictObject({ + token: z.string().uuid(), + }), + handler: async ({ input }) => { + const gta = await prisma.gliederungToAccount.findFirst({ + where: { + confirmByGliederungToken: input.token, + }, + select: { + id: true, + account: { + select: { + email: true, + person: { + select: { + firstname: true, + lastname: true, + }, + }, + }, + }, + gliederung: { + select: { + name: true, + }, + }, + }, + }) + + return gta || false + }, +}) diff --git a/apps/api/src/services/access/access.requestListAll.ts b/apps/api/src/services/access/access.requestListAll.ts new file mode 100644 index 00000000..4087ba64 --- /dev/null +++ b/apps/api/src/services/access/access.requestListAll.ts @@ -0,0 +1,84 @@ +import type { Prisma } from '@prisma/client' +import { z } from 'zod' +import prisma from '../../prisma.js' +import { defineProtectedQueryProcedure } from '../../types/defineProcedure.js' +import { calculatePagination, defineQueryResponse, defineTableInput } from '../../types/defineTableProcedure.js' + +export const listAllGliederungAdminRequestsProcedure = defineProtectedQueryProcedure({ + key: 'listAllGliederungAdminRequests', + roleIds: ['ADMIN'], + inputSchema: defineTableInput({ + filter: { + gliederung: z.string().optional(), + person: z.string().optional(), + }, + orderBy: ['gliederung.name'], + }), + handler: async ({ input: { pagination, filter, orderBy } }) => { + const where: Prisma.GliederungToAccountWhereInput = { + confirmedByGliederung: false, + gliederung: { + name: { + contains: filter?.gliederung, + mode: 'insensitive', + }, + }, + OR: [ + { + account: { + person: { + firstname: { + contains: filter?.person, + mode: 'insensitive', + }, + }, + }, + }, + { + account: { + person: { + lastname: { + contains: filter?.person, + mode: 'insensitive', + }, + }, + }, + }, + ], + } + + const total = await prisma.gliederungToAccount.count({ where }) + const { pageIndex, pageSize, pages } = calculatePagination(total, pagination) + + const requests = await prisma.gliederungToAccount.findMany({ + take: pageSize, + skip: pageSize * pageIndex, + where, + orderBy, + select: { + id: true, + gliederungId: true, + confirmedByGliederung: true, + gliederung: { + select: { + name: true, + }, + }, + account: { + select: { + id: true, + email: true, + person: { + select: { + firstname: true, + lastname: true, + }, + }, + }, + }, + }, + }) + + return defineQueryResponse({ data: requests, total, pagination: { pageIndex, pageSize, pages } }) + }, +}) diff --git a/apps/api/src/services/access/access.requestListOwn.ts b/apps/api/src/services/access/access.requestListOwn.ts new file mode 100644 index 00000000..098d3ab7 --- /dev/null +++ b/apps/api/src/services/access/access.requestListOwn.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import { defineProtectedQueryProcedure } from '../../types/defineProcedure.js' +import prisma from '../../prisma.js' + +export const listOwnGliederungAdminRequestsProcedure = defineProtectedQueryProcedure({ + key: 'listOwnGliederungAdminRequests', + roleIds: ['USER', 'GLIEDERUNG_ADMIN'], + inputSchema: z.void(), + handler: async ({ ctx }) => { + const requests = await prisma.gliederungToAccount.findMany({ + where: { + accountId: ctx.accountId, + confirmedByGliederung: false, + }, + select: { + id: true, + gliederungId: true, + confirmedByGliederung: true, + gliederung: { + select: { + name: true, + }, + }, + }, + }) + + return requests + }, +}) diff --git a/apps/api/src/services/access/access.router.ts b/apps/api/src/services/access/access.router.ts new file mode 100644 index 00000000..94a99622 --- /dev/null +++ b/apps/api/src/services/access/access.router.ts @@ -0,0 +1,14 @@ +import { mergeRouters } from '../../trpc.js' +import { requestGliederungAccessCreateProcedure } from './access.requestGliederungCreate.js' +import { requestGliederungAdminDecideProcedure } from './access.requestGliederungPatch.js' +import { requestGliederungAccessValidateProcedure } from './access.requestGliederungValidate.js' +import { listAllGliederungAdminRequestsProcedure } from './access.requestListAll.js' +import { listOwnGliederungAdminRequestsProcedure } from './access.requestListOwn.js' + +export const accessRouter = mergeRouters( + listOwnGliederungAdminRequestsProcedure, + listAllGliederungAdminRequestsProcedure, + requestGliederungAccessCreateProcedure, + requestGliederungAdminDecideProcedure, + requestGliederungAccessValidateProcedure +) diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index 855a0349..25833c11 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1,4 +1,5 @@ import { router } from '../trpc.js' +import { accessRouter } from './access/access.router.js' import { accountRouter } from './account/account.router.js' import { activityRouter } from './activity/activity.routes.js' @@ -37,5 +38,6 @@ export const serviceRouter = router({ faq: faqsRouter, program: programRouter, anmeldungLink: anmeldungLinkRouter, + access: accessRouter, // Add Routers here - do not delete this line }) diff --git a/apps/api/src/util/getGliederungRequireAdmin.ts b/apps/api/src/util/getGliederungRequireAdmin.ts index d81f5586..3b5e140a 100644 --- a/apps/api/src/util/getGliederungRequireAdmin.ts +++ b/apps/api/src/util/getGliederungRequireAdmin.ts @@ -15,6 +15,8 @@ export async function getGliederungRequireAdmin(accountId: string) { some: { accountId, role: GliederungAccountRole.DELEGATIONSLEITER, + confirmedByGliederung: true, + confirmByGliederungToken: null, }, }, }, diff --git a/apps/api/src/util/zod.ts b/apps/api/src/util/zod.ts index 09fe941c..3872a76a 100644 --- a/apps/api/src/util/zod.ts +++ b/apps/api/src/util/zod.ts @@ -34,3 +34,11 @@ export async function zodSafe(schema: ZodSchema, payload: I): Promise v === 'true') + +export const zEmptyStringAsUndefined = (v: string | undefined) => { + const trimmed = v?.trim() + if (trimmed === undefined || trimmed.length === 0) { + return undefined + } + return trimmed +} diff --git a/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue b/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue index b35dfa6e..c6294dd2 100644 --- a/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue +++ b/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue @@ -11,6 +11,7 @@ import { QueueListIcon, RocketLaunchIcon, UsersIcon, + LockOpenIcon, } from '@heroicons/vue/24/outline' import { computed } from 'vue' import { useRoute } from 'vue-router' @@ -132,6 +133,13 @@ const navigation = computed>(() => [ icon: UsersIcon, visible: hasPermissionToView(['USER']), }, + { + type: 'SidebarItem', + name: 'Gliederungsanfrage', + route: { name: 'Gliederungsanfrage' }, + icon: LockOpenIcon, + visible: hasPermissionToView(['USER']), + }, { type: 'DividerItem', name: 'Gliederung', visible: hasPermissionToView(['GLIEDERUNG_ADMIN']) }, { @@ -171,6 +179,13 @@ const navigation = computed>(() => [ icon: MapPinIcon, visible: hasPermissionToView(['ADMIN']), }, + { + type: 'SidebarItem', + name: 'Gliederungsanfragen', + route: { name: 'Verwaltung Alle Zugriffsanfragen' }, + icon: LockOpenIcon, + visible: hasPermissionToView(['ADMIN']), + }, { type: 'SidebarItem', name: 'Orte', diff --git a/apps/frontend/src/components/forms/gliederung/FormGliederungGeneral.vue b/apps/frontend/src/components/forms/gliederung/FormGliederungGeneral.vue index 78eaf991..80871dc9 100644 --- a/apps/frontend/src/components/forms/gliederung/FormGliederungGeneral.vue +++ b/apps/frontend/src/components/forms/gliederung/FormGliederungGeneral.vue @@ -18,6 +18,7 @@ const fill = (gliederung) => { return { name: gliederung?.name, edv: gliederung?.edv, + email: gliederung?.email, } } @@ -92,6 +93,16 @@ const handle = async (event: Event) => { required /> + +
+ +
diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index 5dd05919..b6e9ed90 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -11,6 +11,8 @@ import { routesUnterveranstaltung } from '@/views/Unterveranstaltung/routes' import routesVeranstaltungen from '@/views/Veranstaltungen/routes' import routesActivity from '@/views/Verwaltung/Activity/routes' import routesVerwaltung from '@/views/Verwaltung/routes' +import routesAnfragen from '@/views/Anfragen/routes' +import routesConfirm from '@/views/Confirm/routes' export type Route = RouteRecordRaw & { meta?: { @@ -23,6 +25,7 @@ const routes: Route[] = [ ...routesPublic, ...routesPublicAnmeldung, ...routesRegistrierung, + ...routesConfirm, { path: '/', redirect: { name: 'Login' }, @@ -33,6 +36,7 @@ const routes: Route[] = [ ...routesDevelopment, ...routesUnterveranstaltung, ...routesActivity, + ...routesAnfragen, { name: 'Dashboard', path: '/dashboard', diff --git a/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue b/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue new file mode 100644 index 00000000..171b7858 --- /dev/null +++ b/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue @@ -0,0 +1,114 @@ + + + diff --git a/apps/frontend/src/views/Anfragen/routes.ts b/apps/frontend/src/views/Anfragen/routes.ts new file mode 100644 index 00000000..8e0cc7b5 --- /dev/null +++ b/apps/frontend/src/views/Anfragen/routes.ts @@ -0,0 +1,18 @@ +import type { Route } from '@/router' + +const routesAnfragen: Route[] = [ + { + name: 'Gliederungsanfrage', + path: '/anfrage/gliederung', + component: () => import('./RequestGliederungAccess.vue'), + meta: { + breadcrumbs: [ + { + text: 'Gliederungsanfrage', + }, + ], + }, + }, +] + +export default routesAnfragen diff --git a/apps/frontend/src/views/Confirm/GliederungAccess.vue b/apps/frontend/src/views/Confirm/GliederungAccess.vue new file mode 100644 index 00000000..81f970fa --- /dev/null +++ b/apps/frontend/src/views/Confirm/GliederungAccess.vue @@ -0,0 +1,176 @@ + + + diff --git a/apps/frontend/src/views/Confirm/routes.ts b/apps/frontend/src/views/Confirm/routes.ts new file mode 100644 index 00000000..96aaf618 --- /dev/null +++ b/apps/frontend/src/views/Confirm/routes.ts @@ -0,0 +1,21 @@ +import type { Route } from '@/router' + +const routesConfirm: Route[] = [ + { + path: '/confirm', + component: () => import('@/layouts/PublicLayout.vue'), + redirect: { name: 'ConfirmGliederungAccess' }, + children: [ + { + name: 'ConfirmGliederungAccess', + path: 'gliederung-access/:token', + component: () => import('./GliederungAccess.vue'), + meta: { + public: true, + }, + }, + ], + }, +] + +export default routesConfirm diff --git a/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue b/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue new file mode 100644 index 00000000..dcbc848b --- /dev/null +++ b/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue @@ -0,0 +1,111 @@ + + + diff --git a/apps/frontend/src/views/Verwaltung/AccessControl/routes.ts b/apps/frontend/src/views/Verwaltung/AccessControl/routes.ts new file mode 100644 index 00000000..9fbb34de --- /dev/null +++ b/apps/frontend/src/views/Verwaltung/AccessControl/routes.ts @@ -0,0 +1,24 @@ +import type { Route } from '@/router' + +const routesAccess: Route[] = [ + { + path: '/access', + redirect: { name: 'Verwaltung Alle Zugriffsanfragen' }, + meta: { + breadcrumbs: [ + { + text: 'Zugriff', + }, + ], + }, + children: [ + { + name: 'Verwaltung Alle Zugriffsanfragen', + path: 'requests', + component: () => import('./GliederungAccessRequests.vue'), + }, + ], + }, +] + +export default routesAccess diff --git a/apps/frontend/src/views/Verwaltung/Gliederungen/GliederungList.vue b/apps/frontend/src/views/Verwaltung/Gliederungen/GliederungList.vue index 9b645206..63e280d7 100644 --- a/apps/frontend/src/views/Verwaltung/Gliederungen/GliederungList.vue +++ b/apps/frontend/src/views/Verwaltung/Gliederungen/GliederungList.vue @@ -30,6 +30,11 @@ const columns = [ enableColumnFilter: true, enableSorting: true, }), + column.accessor('email', { + header: 'Kontaktadresse', + enableColumnFilter: true, + enableSorting: true, + }), ] const query: Query = (pagination, filter, orderBy) => diff --git a/apps/frontend/src/views/Verwaltung/routes.ts b/apps/frontend/src/views/Verwaltung/routes.ts index f393fba8..28142dbb 100644 --- a/apps/frontend/src/views/Verwaltung/routes.ts +++ b/apps/frontend/src/views/Verwaltung/routes.ts @@ -1,4 +1,5 @@ import routesMeineDaten from '../MeineDaten/routes' +import routesAccess from './AccessControl/routes' import routesAccount from './Accounts/routes' import gliederungRoutes from './Gliederungen/routes' import orteRoutes from './Orte/routes' @@ -19,6 +20,7 @@ const routesVerwaltung: Route[] = [ ...orteRoutes, ...routesAccount, ...routesMeineDaten, + ...routesAccess, ], }, ] From 2a230e9776545b67acd5cd1325eb45d3e121b8e6 Mon Sep 17 00:00:00 2001 From: Axel Rindle Date: Thu, 12 Feb 2026 18:03:03 +0100 Subject: [PATCH 02/25] update --- .../gliederung-access-request-decision.mjml | 11 +-- .../services/access/access.requestListAll.ts | 7 +- .../account/accountVerwaltungPatch.ts | 48 ++++++++----- .../LayoutComponents/Sidebar/Sidebar.vue | 2 +- .../GliederungAccessRequests.vue | 72 +++++++++++++++---- 5 files changed, 97 insertions(+), 43 deletions(-) diff --git a/apps/api/email/gliederung-access-request-decision.mjml b/apps/api/email/gliederung-access-request-decision.mjml index 0cbddcaa..4f55c2ef 100644 --- a/apps/api/email/gliederung-access-request-decision.mjml +++ b/apps/api/email/gliederung-access-request-decision.mjml @@ -4,15 +4,8 @@ font-size="16px" line-height="1.5" > - {{ name }} hat soeben einen Antrag auf Zugriff auf die Gliederung {{ gliederung }} gestellt. Bitte prüft den Antrag - und bestätigt diesen, damit {{ name }} Zugriff auf die Ausschreibungen eurer Gliederung erhält. - - - - Nutzt für die Bestätigung oder Ablehnung des Antrags bitte folgenden Link: {{ confirmLink }} + {{#if decision}} Deinem Zugriffsantrag auf die Gliederung {{ gliederung }} wurde zugestimmt. {{else}} Dein + Zugriffsantrag auf die Gliederung {{ gliederung }} wurde abgelehnt. {{/if}} {{/layout}} diff --git a/apps/api/src/services/access/access.requestListAll.ts b/apps/api/src/services/access/access.requestListAll.ts index 4087ba64..adeb6e06 100644 --- a/apps/api/src/services/access/access.requestListAll.ts +++ b/apps/api/src/services/access/access.requestListAll.ts @@ -9,20 +9,21 @@ export const listAllGliederungAdminRequestsProcedure = defineProtectedQueryProce roleIds: ['ADMIN'], inputSchema: defineTableInput({ filter: { - gliederung: z.string().optional(), - person: z.string().optional(), + gliederung: z.string(), + person: z.string(), + confirmed: z.boolean(), }, orderBy: ['gliederung.name'], }), handler: async ({ input: { pagination, filter, orderBy } }) => { const where: Prisma.GliederungToAccountWhereInput = { - confirmedByGliederung: false, gliederung: { name: { contains: filter?.gliederung, mode: 'insensitive', }, }, + confirmedByGliederung: filter?.confirmed, OR: [ { account: { diff --git a/apps/api/src/services/account/accountVerwaltungPatch.ts b/apps/api/src/services/account/accountVerwaltungPatch.ts index fd2e16cb..7a8aaceb 100644 --- a/apps/api/src/services/account/accountVerwaltungPatch.ts +++ b/apps/api/src/services/account/accountVerwaltungPatch.ts @@ -25,30 +25,44 @@ export const accountVerwaltungPatchProcedure = defineProtectedMutateProcedure({ }, select: { status: true, + role: true, }, }) - const account = await prisma.account.update({ - where: { - id: options.input.id, - }, - data: options.input.data, - select: { - id: true, - status: true, - email: true, - person: { - select: { - firstname: true, - lastname: true, - gliederung: { - select: { - name: true, + const account = await prisma.$transaction(async (tx) => { + const account = await tx.account.update({ + where: { + id: options.input.id, + }, + data: options.input.data, + select: { + id: true, + status: true, + email: true, + role: true, + person: { + select: { + firstname: true, + lastname: true, + gliederung: { + select: { + name: true, + }, }, }, }, }, - }, + }) + + if (account.role !== 'GLIEDERUNG_ADMIN') { + await tx.gliederungToAccount.deleteMany({ + where: { + accountId: account.id, + }, + }) + } + + return account }) if (oldAccount.status !== account.status) { diff --git a/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue b/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue index c6294dd2..cb1c4e75 100644 --- a/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue +++ b/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue @@ -181,7 +181,7 @@ const navigation = computed>(() => [ }, { type: 'SidebarItem', - name: 'Gliederungsanfragen', + name: 'Gliederungsadmins', route: { name: 'Verwaltung Alle Zugriffsanfragen' }, icon: LockOpenIcon, visible: hasPermissionToView(['ADMIN']), diff --git a/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue b/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue index dcbc848b..e2b5a5c7 100644 --- a/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue +++ b/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue @@ -1,13 +1,16 @@ + + diff --git a/apps/frontend/src/helpers/constants.ts b/apps/frontend/src/helpers/constants.ts new file mode 100644 index 00000000..0887d0da --- /dev/null +++ b/apps/frontend/src/helpers/constants.ts @@ -0,0 +1,8 @@ +import type { Role } from '@codeanker/api' +import type { StatusColors } from './getAccountStatusColors' + +export const roleColors: Record = { + ADMIN: 'danger', + GLIEDERUNG_ADMIN: 'warning', + USER: 'muted', +} diff --git a/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue b/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue index bfdaa007..36b35a03 100644 --- a/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue +++ b/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue @@ -1,16 +1,18 @@ + + + + + + diff --git a/apps/frontend/src/views/Verwaltung/Accounts/AccountList.vue b/apps/frontend/src/views/Verwaltung/Accounts/AccountList.vue index 57edf68e..089f7a58 100644 --- a/apps/frontend/src/views/Verwaltung/Accounts/AccountList.vue +++ b/apps/frontend/src/views/Verwaltung/Accounts/AccountList.vue @@ -14,17 +14,13 @@ import { keepPreviousData, useQuery } from '@tanstack/vue-query' import { createColumnHelper } from '@tanstack/vue-table' import { h } from 'vue' import { useRouter } from 'vue-router' +import { roleColors } from '@/helpers/constants' const { setTitle } = useRouteTitle() setTitle('Accounts') type Account = RouterOutput['account']['verwaltungList']['data'][number] -const roleColors: Record = { - ADMIN: 'danger', - GLIEDERUNG_ADMIN: 'warning', - USER: 'muted', -} const statusColors: Record = { AKTIV: 'primary', DEAKTIVIERT: 'danger', From 04e020f1c051038c68f3d48263713c62c9778abf Mon Sep 17 00:00:00 2001 From: Axel Rindle Date: Sun, 15 Feb 2026 21:52:52 +0100 Subject: [PATCH 09/25] fix: add public endpoint --- .../access/access.requestGliederungConfirm.ts | 111 ++++++++++++++++++ .../access/access.requestGliederungPatch.ts | 52 ++++---- apps/api/src/services/access/access.router.ts | 4 +- .../src/views/Confirm/GliederungAccess.vue | 7 +- 4 files changed, 149 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/services/access/access.requestGliederungConfirm.ts diff --git a/apps/api/src/services/access/access.requestGliederungConfirm.ts b/apps/api/src/services/access/access.requestGliederungConfirm.ts new file mode 100644 index 00000000..dedd1f3f --- /dev/null +++ b/apps/api/src/services/access/access.requestGliederungConfirm.ts @@ -0,0 +1,111 @@ +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import prisma from '../../prisma.js' +import { definePublicMutateProcedure } from '../../types/defineProcedure.js' +import logActivity from '../../util/activity.js' +import { sendMail } from '../../util/mail.js' + +export const requestGliederungAdminConfirmProcedure = definePublicMutateProcedure({ + key: 'requestGliederungAdminConfirm', + inputSchema: z.strictObject({ + token: z.string().uuid(), + decision: z.boolean(), + }), + handler: async ({ ctx, input: { token, decision } }) => { + const request = await prisma.gliederungToAccount.findFirst({ + where: { + confirmByGliederungToken: token, + }, + select: { + id: true, + accountId: true, + account: { + select: { + email: true, + }, + }, + gliederung: { + select: { + name: true, + }, + }, + }, + }) + if (request === null) { + throw new TRPCError({ + code: 'NOT_FOUND', + }) + } + + if (!decision) { + await prisma.$transaction(async (tx) => { + await tx.gliederungToAccount.delete({ + where: { + id: request.id, + }, + }) + await tx.account.update({ + where: { + id: request.accountId, + }, + data: { + role: 'USER', + }, + }) + }) + + await logActivity({ + type: 'DELETE', + subjectType: 'gliederungtoaccount', + subjectId: `${request.id}`, + description: `Eine Zugriffsanfrage auf die Gliederung ${request.gliederung.name} wurde abgelehnt.`, + causerId: ctx.accountId, + }) + } else { + await prisma.$transaction(async (tx) => { + await tx.gliederungToAccount.updateMany({ + where: { + confirmByGliederungToken: token, + }, + data: { + confirmByGliederungToken: null, + confirmedByGliederung: true, + confirmedAt: new Date(), + }, + }) + await tx.account.update({ + where: { + id: request.accountId, + }, + data: { + role: 'GLIEDERUNG_ADMIN', + }, + }) + }) + + await logActivity({ + type: 'UPDATE', + subjectType: 'account', + subjectId: request.accountId, + description: `Account ${request.account.email} ist jetzt Gliederungsadmin der Gliederung ${request.gliederung.name}`, + causerId: ctx.accountId, + }) + } + + await sendMail({ + to: request.account.email, + categories: ['access', 'gliederung'], + template: 'gliederung-access-request-decision', + subject: `Zugriffsanfrage auf deine Gliederung ${decision ? 'bestätigt' : 'abgelehnt'}`, + variables: { + hostname: 'brahmsee.digital', + gliederung: request.gliederung.name, + name: `${ctx.account?.person.firstname} ${ctx.account?.person.lastname}`, + veranstaltung: 'Zugriffsanfrage', + decision, + }, + }) + + return decision + }, +}) diff --git a/apps/api/src/services/access/access.requestGliederungPatch.ts b/apps/api/src/services/access/access.requestGliederungPatch.ts index 046b1e8f..7d0af8e6 100644 --- a/apps/api/src/services/access/access.requestGliederungPatch.ts +++ b/apps/api/src/services/access/access.requestGliederungPatch.ts @@ -1,30 +1,29 @@ +import { TRPCError } from '@trpc/server' import { z } from 'zod' -import { defineProtectedMutateProcedure } from '../../types/defineProcedure.js' import prisma from '../../prisma.js' +import { defineProtectedMutateProcedure } from '../../types/defineProcedure.js' import logActivity from '../../util/activity.js' -import { TRPCError } from '@trpc/server' +import { getGliederungRequireAdmin } from '../../util/getGliederungRequireAdmin.js' import { sendMail } from '../../util/mail.js' export const requestGliederungAdminDecideProcedure = defineProtectedMutateProcedure({ key: 'requestGliederungAdminDecide', roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'], inputSchema: z.strictObject({ - token: z.string().uuid().optional(), requestId: z.number().int(), decision: z.boolean(), }), - handler: async ({ ctx, input: { token, requestId, decision } }) => { - if (ctx.account?.role === 'GLIEDERUNG_ADMIN' && token === undefined) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'token is required', - }) + handler: async ({ ctx, input: { requestId, decision } }) => { + let gliederungId: string | undefined = undefined + if (ctx.account.role === 'GLIEDERUNG_ADMIN') { + const gliederung = await getGliederungRequireAdmin(ctx.accountId) + gliederungId = gliederung.id } const request = await prisma.gliederungToAccount.findUnique({ where: { id: requestId, - confirmByGliederungToken: ctx.account?.role === 'ADMIN' ? undefined : token, + gliederungId, }, select: { id: true, @@ -32,6 +31,12 @@ export const requestGliederungAdminDecideProcedure = defineProtectedMutateProced account: { select: { email: true, + person: { + select: { + firstname: true, + lastname: true, + }, + }, }, }, gliederung: { @@ -48,10 +53,20 @@ export const requestGliederungAdminDecideProcedure = defineProtectedMutateProced } if (!decision) { - await prisma.gliederungToAccount.delete({ - where: { - id: requestId, - }, + await prisma.$transaction(async (tx) => { + await tx.gliederungToAccount.delete({ + where: { + id: requestId, + }, + }) + await tx.account.update({ + where: { + id: request.accountId, + }, + data: { + role: 'USER', + }, + }) }) await logActivity({ @@ -63,7 +78,7 @@ export const requestGliederungAdminDecideProcedure = defineProtectedMutateProced }) } else { await prisma.$transaction(async (tx) => { - const { accountId } = await tx.gliederungToAccount.update({ + await tx.gliederungToAccount.update({ where: { id: requestId, }, @@ -72,13 +87,10 @@ export const requestGliederungAdminDecideProcedure = defineProtectedMutateProced confirmedByGliederung: true, confirmedAt: new Date(), }, - select: { - accountId: true, - }, }) await tx.account.update({ where: { - id: accountId, + id: request.accountId, }, data: { role: 'GLIEDERUNG_ADMIN', @@ -103,7 +115,7 @@ export const requestGliederungAdminDecideProcedure = defineProtectedMutateProced variables: { hostname: 'brahmsee.digital', gliederung: request.gliederung.name, - name: `${ctx.account?.person.firstname} ${ctx.account?.person.lastname}`, + name: `${request.account.person.firstname} ${request.account.person.lastname}`, veranstaltung: 'Zugriffsanfrage', decision, }, diff --git a/apps/api/src/services/access/access.router.ts b/apps/api/src/services/access/access.router.ts index 3ce5b90e..b4519dcb 100644 --- a/apps/api/src/services/access/access.router.ts +++ b/apps/api/src/services/access/access.router.ts @@ -1,5 +1,6 @@ import { mergeRouters } from '../../trpc.js' import { createAccessForGliederungProcedure } from './access.createForGliederung.js' +import { requestGliederungAdminConfirmProcedure } from './access.requestGliederungConfirm.js' import { requestGliederungAccessCreateProcedure } from './access.requestGliederungCreate.js' import { requestGliederungAdminDecideProcedure } from './access.requestGliederungPatch.js' import { requestGliederungAccessValidateProcedure } from './access.requestGliederungValidate.js' @@ -12,5 +13,6 @@ export const accessRouter = mergeRouters( requestGliederungAccessCreateProcedure, requestGliederungAdminDecideProcedure, requestGliederungAccessValidateProcedure, - createAccessForGliederungProcedure + createAccessForGliederungProcedure, + requestGliederungAdminConfirmProcedure ) diff --git a/apps/frontend/src/views/Confirm/GliederungAccess.vue b/apps/frontend/src/views/Confirm/GliederungAccess.vue index 81f970fa..64153415 100644 --- a/apps/frontend/src/views/Confirm/GliederungAccess.vue +++ b/apps/frontend/src/views/Confirm/GliederungAccess.vue @@ -34,9 +34,9 @@ const { isSuccess: decideIsSuccess, isPending: decideIsPending, } = useMutation({ - mutationKey: ['requestGliederungAdminDecide'], - mutationFn: (input: RouterInput['access']['requestGliederungAdminDecide']) => - apiClient.access.requestGliederungAdminDecide.mutate(input), + mutationKey: ['requestGliederungAdminConfirm'], + mutationFn: (input: RouterInput['access']['requestGliederungAdminConfirm']) => + apiClient.access.requestGliederungAdminConfirm.mutate(input), }) async function doDecide(decision: boolean) { @@ -46,7 +46,6 @@ async function doDecide(decision: boolean) { await mutateAsync({ token: token.value, - requestId: data.value.id, decision, }) } From 313ee4e8a2b9ccfe160fdb5f4ff1d293bdf0c3e1 Mon Sep 17 00:00:00 2001 From: Axel Rindle Date: Sun, 15 Feb 2026 21:56:04 +0100 Subject: [PATCH 10/25] fix: update account role --- .../api/src/services/access/access.createForGliederung.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/api/src/services/access/access.createForGliederung.ts b/apps/api/src/services/access/access.createForGliederung.ts index 6f304abb..9fcd1693 100644 --- a/apps/api/src/services/access/access.createForGliederung.ts +++ b/apps/api/src/services/access/access.createForGliederung.ts @@ -40,6 +40,14 @@ export const createAccessForGliederungProcedure = defineProtectedMutateProcedure }, }, }) + await tx.account.update({ + where: { + id: input.accountId, + }, + data: { + role: 'GLIEDERUNG_ADMIN', + }, + }) const description = ` Der Account von ${record.account.person.firstname} ${record.account.person.lastname} (${record.account.email}) hat From c948a7477aa0509856592740db0870e13bafde1d Mon Sep 17 00:00:00 2001 From: Axel Rindle Date: Sun, 15 Feb 2026 22:02:59 +0100 Subject: [PATCH 11/25] refactor: add request modal --- .../Anfragen/RequestGliederungAccess.vue | 143 ++++++++++-------- 1 file changed, 82 insertions(+), 61 deletions(-) diff --git a/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue b/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue index 171b7858..cc917cba 100644 --- a/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue +++ b/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue @@ -3,10 +3,11 @@ import { apiClient } from '@/api' import BasicTypeahead from '@/components/BasicInputs/BasicTypeahead.vue' import Button from '@/components/UIComponents/Button.vue' import Loading from '@/components/UIComponents/Loading.vue' +import Modal from '@/components/UIComponents/Modal.vue' import { loggedInAccount } from '@/composables/useAuthentication' -import { CheckCircleIcon, XMarkIcon } from '@heroicons/vue/24/outline' +import { CheckCircleIcon, PlusIcon, XMarkIcon } from '@heroicons/vue/24/outline' import { useMutation, useQuery } from '@tanstack/vue-query' -import { computed, ref } from 'vue' +import { computed, ref, useTemplateRef } from 'vue' import { useRouter } from 'vue-router' const router = useRouter() @@ -34,6 +35,8 @@ async function queryObjectGliederungen(searchTerm: string) { }) } +const modalAdd = useTemplateRef('modalAdd') + const { mutate, error, isPending, isSuccess, isError } = useMutation({ mutationKey: ['requestGliederungAdminCreate'], mutationFn: async () => { @@ -42,73 +45,91 @@ const { mutate, error, isPending, isSuccess, isError } = useMutation({ } await apiClient.access.requestGliederungAdminCreate.mutate({ gliederungId: gliederung.value.id }) + modalAdd.value?.hide() await refetch() }, }) From d0c56dbadc377070d56d83078e16d80ff859c460 Mon Sep 17 00:00:00 2001 From: Axel Rindle Date: Sun, 15 Feb 2026 22:03:10 +0100 Subject: [PATCH 12/25] refactor: delete old view --- .../Verwaltung/Gliederungen/GliederungAccountanfrage.vue | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 apps/frontend/src/views/Verwaltung/Gliederungen/GliederungAccountanfrage.vue diff --git a/apps/frontend/src/views/Verwaltung/Gliederungen/GliederungAccountanfrage.vue b/apps/frontend/src/views/Verwaltung/Gliederungen/GliederungAccountanfrage.vue deleted file mode 100644 index 6bef4862..00000000 --- a/apps/frontend/src/views/Verwaltung/Gliederungen/GliederungAccountanfrage.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - From a5f534a969f3b975029c35866dd4e7bb30572e65 Mon Sep 17 00:00:00 2001 From: Axel Rindle Date: Sun, 15 Feb 2026 22:05:52 +0100 Subject: [PATCH 13/25] fix: remove route --- .../src/views/Verwaltung/Gliederungen/routes.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/frontend/src/views/Verwaltung/Gliederungen/routes.ts b/apps/frontend/src/views/Verwaltung/Gliederungen/routes.ts index 80f66a60..d6c491ad 100644 --- a/apps/frontend/src/views/Verwaltung/Gliederungen/routes.ts +++ b/apps/frontend/src/views/Verwaltung/Gliederungen/routes.ts @@ -49,18 +49,6 @@ const gliederungRoutes: Route[] = [ ], }, }, - { - name: 'Verwaltung Gliederung Account anfragen', - path: 'anfrage', - component: () => import('./GliederungAccountanfrage.vue'), - meta: { - breadcrumbs: [ - { - text: 'Anfragen', - }, - ], - }, - }, ], }, ] From 670863b17f5a660162bc1e7f90d17013259d9064 Mon Sep 17 00:00:00 2001 From: Axel Rindle Date: Sun, 15 Feb 2026 22:11:04 +0100 Subject: [PATCH 14/25] feat: allow view access to own gliederung --- .../services/access/access.requestListAll.ts | 12 +++++++++-- .../LayoutComponents/Sidebar/Sidebar.vue | 7 +++++++ .../GliederungAccessRequests.vue | 21 ++++++++++++------- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/api/src/services/access/access.requestListAll.ts b/apps/api/src/services/access/access.requestListAll.ts index 7e384f4c..40339d7a 100644 --- a/apps/api/src/services/access/access.requestListAll.ts +++ b/apps/api/src/services/access/access.requestListAll.ts @@ -3,10 +3,11 @@ import { z } from 'zod' import prisma from '../../prisma.js' import { defineProtectedQueryProcedure } from '../../types/defineProcedure.js' import { calculatePagination, defineQueryResponse, defineTableInput } from '../../types/defineTableProcedure.js' +import { getGliederungRequireAdmin } from '../../util/getGliederungRequireAdmin.js' export const listAllGliederungAdminRequestsProcedure = defineProtectedQueryProcedure({ key: 'listAllGliederungAdminRequests', - roleIds: ['ADMIN'], + roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'], inputSchema: defineTableInput({ filter: { gliederung: z.string(), @@ -15,8 +16,15 @@ export const listAllGliederungAdminRequestsProcedure = defineProtectedQueryProce }, orderBy: ['gliederung.name'], }), - handler: async ({ input: { pagination, filter, orderBy } }) => { + handler: async ({ ctx, input: { pagination, filter, orderBy } }) => { + let gliederungId: string | undefined = undefined + if (ctx.account.role === 'GLIEDERUNG_ADMIN') { + const gliederung = await getGliederungRequireAdmin(ctx.accountId) + gliederungId = gliederung.id + } + const where: Prisma.GliederungToAccountWhereInput = { + gliederungId, gliederung: { name: { contains: filter?.gliederung, diff --git a/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue b/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue index 355a2ed0..9baae88b 100644 --- a/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue +++ b/apps/frontend/src/components/LayoutComponents/Sidebar/Sidebar.vue @@ -154,6 +154,13 @@ const navigation = computed>(() => [ icon: MegaphoneIcon, visible: hasPermissionToView(['GLIEDERUNG_ADMIN']), }, + { + type: 'SidebarItem', + name: 'Berechtigungen', + route: { name: 'Verwaltung Alle Zugriffsanfragen' }, + icon: LockOpenIcon, + visible: hasPermissionToView(['GLIEDERUNG_ADMIN']), + }, { type: 'DividerItem', name: 'Verwaltung', visible: hasPermissionToView(['ADMIN']) }, { diff --git a/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue b/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue index 36b35a03..0e88211a 100644 --- a/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue +++ b/apps/frontend/src/views/Verwaltung/AccessControl/GliederungAccessRequests.vue @@ -7,6 +7,7 @@ import DataTable from '@/components/Table/DataTable.vue' import initialData from '@/components/Table/initialData' import Button from '@/components/UIComponents/Button.vue' import Modal from '@/components/UIComponents/Modal.vue' +import { loggedInAccount } from '@/composables/useAuthentication' import type { RouterInput, RouterOutput } from '@codeanker/api' import { dayjs } from '@codeanker/helpers' import { ArrowTopRightOnSquareIcon, PlusIcon } from '@heroicons/vue/24/outline' @@ -84,6 +85,7 @@ const columns = [ header: 'Entscheidung', cell: ({ row }) => { if (row.original.confirmedByGliederung) { + const isSelf = row.original.account.id === loggedInAccount.value?.id return h( 'div', { @@ -97,14 +99,16 @@ const columns = [ }, 'Bestätigt' ), - h( - Button, - { - color: 'danger', - onClick: () => decide.mutate({ requestId: row.original.id, decision: false }), - }, - 'Widerrufen' - ), + isSelf + ? undefined + : h( + Button, + { + color: 'danger', + onClick: () => decide.mutate({ requestId: row.original.id, decision: false }), + }, + 'Widerrufen' + ), ] ) } @@ -173,6 +177,7 @@ const modalAdd = useTemplateRef('modalAdd')

Hier findest du alle Accounts mit Zugriff auf Gliederungen.

From a4d928b228ec24d22d732179796e314ff1714baa Mon Sep 17 00:00:00 2001 From: Axel Rindle Date: Fri, 20 Feb 2026 14:45:15 +0100 Subject: [PATCH 15/25] increase modal size --- apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue | 2 +- .../views/Verwaltung/AccessControl/GliederungAccessRequests.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue b/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue index cc917cba..d394d4cf 100644 --- a/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue +++ b/apps/frontend/src/views/Anfragen/RequestGliederungAccess.vue @@ -81,7 +81,7 @@ const { mutate, error, isPending, isSuccess, isError } = useMutation({