From c8b25321e73512dd738a1a76671e8cf7acd86559 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Sat, 14 Mar 2026 10:47:36 +0100 Subject: [PATCH] =?UTF-8?q?feat(core+frontend):=20Profilzugriff-Gruppen=20?= =?UTF-8?q?f=C3=BCr=20Admin=20mit=20delegierten=20Berechtigungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Prisma-Modelle ProfileAccessGroup + ProfileAccessGroupMember mit canView/canExport/canEdit - Manuelle Migration 20260314_profile_access_groups - ProfileAccessModule: CRUD-Endpoints für Gruppen und Mitglieder (nur PLATFORM_ADMIN) - Neue Admin-Endpoints in ExpertProfileService/-Controller für alle Profil-Mutationen - verifyOwnership mit skipCheck-Parameter für Admin-Bypass - ExpertProfileTab + alle Section-Komponenten erhalten apiBase-Prop für Wiederverwendung - AdminProfileAccessPage: Gruppen-Tab (CRUD) + Profile-Tab (alle User mit Aktionen) - AdminProfileDetailPage: Profil eines beliebigen Users im Admin-Kontext bearbeiten - Route /admin/profile-access + /admin/profiles/:userId + Nav-Tab Profilzugriff Co-Authored-By: Claude Sonnet 4.6 --- .../core-service/prisma/core.schema.prisma | 41 +- .../migration.sql | 32 ++ packages/core-service/src/app.module.ts | 2 + .../expert-profile.controller.ts | 188 +++++++ .../expert-profile/expert-profile.module.ts | 2 + .../expert-profile/expert-profile.service.ts | 86 ++++ .../profile-access.controller.ts | 119 +++++ .../profile-access/profile-access.module.ts | 10 + .../profile-access/profile-access.service.ts | 200 ++++++++ packages/frontend/src/admin/AdminLayout.tsx | 1 + .../src/admin/AdminProfileAccessPage.tsx | 477 ++++++++++++++++++ .../src/admin/AdminProfileDetailPage.tsx | 100 ++++ .../frontend/src/profile/ExpertProfileTab.tsx | 22 +- .../profile/sections/CertificationModal.tsx | 7 +- .../sections/CertificationsSection.tsx | 6 +- .../profile/sections/ExperienceSection.tsx | 7 +- .../src/profile/sections/LanguagesSection.tsx | 7 +- .../src/profile/sections/ProjectModal.tsx | 7 +- .../src/profile/sections/ProjectsSection.tsx | 6 +- .../src/profile/sections/SkillsSection.tsx | 7 +- packages/frontend/src/shell/App.tsx | 5 + 21 files changed, 1300 insertions(+), 32 deletions(-) create mode 100644 packages/core-service/prisma/migrations/20260314_profile_access_groups/migration.sql create mode 100644 packages/core-service/src/core/profile-access/profile-access.controller.ts create mode 100644 packages/core-service/src/core/profile-access/profile-access.module.ts create mode 100644 packages/core-service/src/core/profile-access/profile-access.service.ts create mode 100644 packages/frontend/src/admin/AdminProfileAccessPage.tsx create mode 100644 packages/frontend/src/admin/AdminProfileDetailPage.tsx diff --git a/packages/core-service/prisma/core.schema.prisma b/packages/core-service/prisma/core.schema.prisma index 280c3c1..7a18d0c 100644 --- a/packages/core-service/prisma/core.schema.prisma +++ b/packages/core-service/prisma/core.schema.prisma @@ -57,10 +57,11 @@ model User { // Relationen authProvider AuthProvider[] - tenantMemberships TenantMembership[] - auditLogs AuditLog[] - expertProfile ExpertProfile? - integrations UserIntegration[] + tenantMemberships TenantMembership[] + auditLogs AuditLog[] + expertProfile ExpertProfile? + integrations UserIntegration[] + profileAccessGroups ProfileAccessGroupMember[] @@map("users") } @@ -90,6 +91,38 @@ model UserIntegration { @@map("user_integrations") } +// -------------------------------------------------------- +// ProfileAccessGroup - Benutzergruppen fuer Profilzugriff +// -------------------------------------------------------- +model ProfileAccessGroup { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(100) + description String? @db.Text + canView Boolean @default(true) @map("can_view") + canExport Boolean @default(false) @map("can_export") + canEdit Boolean @default(false) @map("can_edit") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + members ProfileAccessGroupMember[] + + @@map("profile_access_groups") +} + +model ProfileAccessGroupMember { + id String @id @default(uuid()) @db.Uuid + groupId String @map("group_id") @db.Uuid + userId String @map("user_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + + group ProfileAccessGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([groupId, userId]) + @@map("profile_access_group_members") +} + // -------------------------------------------------------- // AuthProvider - Authentifizierungs-Provider pro User // -------------------------------------------------------- diff --git a/packages/core-service/prisma/migrations/20260314_profile_access_groups/migration.sql b/packages/core-service/prisma/migrations/20260314_profile_access_groups/migration.sql new file mode 100644 index 0000000..3b3df71 --- /dev/null +++ b/packages/core-service/prisma/migrations/20260314_profile_access_groups/migration.sql @@ -0,0 +1,32 @@ +-- CreateTable +CREATE TABLE "profile_access_groups" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" VARCHAR(100) NOT NULL, + "description" TEXT, + "can_view" BOOLEAN NOT NULL DEFAULT true, + "can_export" BOOLEAN NOT NULL DEFAULT false, + "can_edit" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "profile_access_groups_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "profile_access_group_members" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "group_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "profile_access_group_members_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "profile_access_group_members_group_id_user_id_key" ON "profile_access_group_members"("group_id", "user_id"); + +-- AddForeignKey +ALTER TABLE "profile_access_group_members" ADD CONSTRAINT "profile_access_group_members_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "profile_access_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "profile_access_group_members" ADD CONSTRAINT "profile_access_group_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/core-service/src/app.module.ts b/packages/core-service/src/app.module.ts index d145bed..6ff0c1d 100644 --- a/packages/core-service/src/app.module.ts +++ b/packages/core-service/src/app.module.ts @@ -12,6 +12,7 @@ import { TenantsModule } from './core/tenants/tenants.module'; import { ExpertProfileModule } from './core/expert-profile/expert-profile.module'; import { SettingsModule } from './core/settings/settings.module'; import { IntegrationsModule } from './core/integrations/integrations.module'; +import { ProfileAccessModule } from './core/profile-access/profile-access.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { validateConfig } from './config/env.validation'; @@ -46,6 +47,7 @@ import { validateConfig } from './config/env.validation'; ExpertProfileModule, SettingsModule, IntegrationsModule, + ProfileAccessModule, ], providers: [ // Global Guards: Alle Routen sind standardmaessig geschuetzt diff --git a/packages/core-service/src/core/expert-profile/expert-profile.controller.ts b/packages/core-service/src/core/expert-profile/expert-profile.controller.ts index 77771e3..618d658 100644 --- a/packages/core-service/src/core/expert-profile/expert-profile.controller.ts +++ b/packages/core-service/src/core/expert-profile/expert-profile.controller.ts @@ -16,6 +16,7 @@ import type { Response } from 'express'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { ExpertProfileService } from './expert-profile.service'; import { ProfileExportService } from './profile-export.service'; +import { ProfileAccessService } from '../profile-access/profile-access.service'; import { UpdateSkillsDto } from './dto/update-skills.dto'; import { CreateExperienceDto } from './dto/create-experience.dto'; import { CreateLanguageDto } from './dto/create-language.dto'; @@ -32,6 +33,7 @@ export class ExpertProfileController { constructor( private readonly expertProfileService: ExpertProfileService, private readonly profileExportService: ProfileExportService, + private readonly profileAccessService: ProfileAccessService, ) {} // ============================================================ @@ -230,4 +232,190 @@ export class ExpertProfileController { ) { await this.expertProfileService.deleteAttachment(userId, id); } + + // ============================================================ + // Admin-Zugriff: alle Nutzerprofile (PLATFORM_ADMIN oder Gruppe) + // ============================================================ + + @Get('admin/users') + @ApiOperation({ summary: 'Alle Nutzer mit Profilübersicht (Admin)' }) + async adminListUsers(@CurrentUser('sub') requestingUserId: string) { + await this.profileAccessService.assertAccess(requestingUserId, 'canView'); + return this.profileAccessService.getAllUsersWithProfileSummary(); + } + + @Get('admin/users/:userId') + @ApiOperation({ summary: 'Profil eines Nutzers abrufen (Admin)' }) + async adminGetProfile( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canView'); + return this.expertProfileService.getOrCreateProfileAdmin(targetUserId); + } + + @Get('admin/users/:userId/export/pdf') + @ApiOperation({ summary: 'Profil als PDF exportieren (Admin)' }) + async adminExportPdf( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Res() res: Response, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canExport'); + const { buffer, firstName, lastName } = await this.profileExportService.generatePdf(targetUserId); + const baseName = `${firstName}_${lastName}_CV`.replace(/\s+/g, '_'); + const encodedName = encodeURIComponent(`${baseName}.pdf`); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${baseName}.pdf"; filename*=UTF-8''${encodedName}`, + 'Content-Length': String(buffer.length), + }); + res.end(buffer); + } + + @Get('admin/users/:userId/export/docx') + @ApiOperation({ summary: 'Profil als Word exportieren (Admin)' }) + async adminExportDocx( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Res() res: Response, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canExport'); + const { buffer, firstName, lastName } = await this.profileExportService.generateDocx(targetUserId); + const baseName = `${firstName}_${lastName}_CV`.replace(/\s+/g, '_'); + const encodedName = encodeURIComponent(`${baseName}.docx`); + res.set({ + 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'Content-Disposition': `attachment; filename="${baseName}.docx"; filename*=UTF-8''${encodedName}`, + 'Content-Length': String(buffer.length), + }); + res.end(buffer); + } + + @Patch('admin/users/:userId/skills') + @ApiOperation({ summary: 'Skills eines Nutzers aktualisieren (Admin)' }) + async adminUpdateSkills( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Body() dto: UpdateSkillsDto, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + return this.expertProfileService.updateSkillsAdmin(targetUserId, dto); + } + + @Post('admin/users/:userId/experiences') + @ApiOperation({ summary: 'Erfahrung eines Nutzers hinzufügen (Admin)' }) + async adminAddExperience( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Body() dto: CreateExperienceDto, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + return this.expertProfileService.addExperienceAdmin(targetUserId, dto); + } + + @Delete('admin/users/:userId/experiences/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Erfahrung eines Nutzers löschen (Admin)' }) + async adminDeleteExperience( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + await this.expertProfileService.deleteExperienceAdmin(targetUserId, id); + } + + @Post('admin/users/:userId/languages') + @ApiOperation({ summary: 'Sprache eines Nutzers hinzufügen (Admin)' }) + async adminAddLanguage( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Body() dto: CreateLanguageDto, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + return this.expertProfileService.addLanguageAdmin(targetUserId, dto); + } + + @Delete('admin/users/:userId/languages/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Sprache eines Nutzers löschen (Admin)' }) + async adminDeleteLanguage( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + await this.expertProfileService.deleteLanguageAdmin(targetUserId, id); + } + + @Post('admin/users/:userId/projects') + @ApiOperation({ summary: 'Projekt eines Nutzers hinzufügen (Admin)' }) + async adminAddProject( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Body() dto: CreateProjectDto, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + return this.expertProfileService.addProjectAdmin(targetUserId, dto); + } + + @Patch('admin/users/:userId/projects/:id') + @ApiOperation({ summary: 'Projekt eines Nutzers bearbeiten (Admin)' }) + async adminUpdateProject( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateProjectDto, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + return this.expertProfileService.updateProjectAdmin(targetUserId, id, dto); + } + + @Delete('admin/users/:userId/projects/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Projekt eines Nutzers löschen (Admin)' }) + async adminDeleteProject( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + await this.expertProfileService.deleteProjectAdmin(targetUserId, id); + } + + @Post('admin/users/:userId/certifications') + @ApiOperation({ summary: 'Zertifizierung eines Nutzers hinzufügen (Admin)' }) + async adminAddCertification( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Body() dto: CreateCertificationDto, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + return this.expertProfileService.addCertificationAdmin(targetUserId, dto); + } + + @Patch('admin/users/:userId/certifications/:id') + @ApiOperation({ summary: 'Zertifizierung eines Nutzers bearbeiten (Admin)' }) + async adminUpdateCertification( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCertificationDto, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + return this.expertProfileService.updateCertificationAdmin(targetUserId, id, dto); + } + + @Delete('admin/users/:userId/certifications/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Zertifizierung eines Nutzers löschen (Admin)' }) + async adminDeleteCertification( + @CurrentUser('sub') requestingUserId: string, + @Param('userId', ParseUUIDPipe) targetUserId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.profileAccessService.assertAccess(requestingUserId, 'canEdit'); + await this.expertProfileService.deleteCertificationAdmin(targetUserId, id); + } } diff --git a/packages/core-service/src/core/expert-profile/expert-profile.module.ts b/packages/core-service/src/core/expert-profile/expert-profile.module.ts index 0d3106c..73999df 100644 --- a/packages/core-service/src/core/expert-profile/expert-profile.module.ts +++ b/packages/core-service/src/core/expert-profile/expert-profile.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { ExpertProfileController } from './expert-profile.controller'; import { ExpertProfileService } from './expert-profile.service'; import { ProfileExportService } from './profile-export.service'; +import { ProfileAccessModule } from '../profile-access/profile-access.module'; @Module({ + imports: [ProfileAccessModule], controllers: [ExpertProfileController], providers: [ExpertProfileService, ProfileExportService], exports: [ExpertProfileService], diff --git a/packages/core-service/src/core/expert-profile/expert-profile.service.ts b/packages/core-service/src/core/expert-profile/expert-profile.service.ts index edcc1ec..0a92671 100644 --- a/packages/core-service/src/core/expert-profile/expert-profile.service.ts +++ b/packages/core-service/src/core/expert-profile/expert-profile.service.ts @@ -333,7 +333,10 @@ export class ExpertProfileService { userId: string, model: 'expertExperience' | 'expertLanguage' | 'expertProject' | 'expertCertification', entityId: string, + skipCheck = false, ): Promise { + if (skipCheck) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const entity = await (this.prisma[model] as any).findUnique({ where: { id: entityId }, @@ -348,4 +351,87 @@ export class ExpertProfileService { throw new ForbiddenException('Kein Zugriff auf diesen Eintrag'); } } + + // ============================================================ + // Admin-Methoden (kein Ownership-Check) + // ============================================================ + + async getOrCreateProfileAdmin(targetUserId: string) { + return this.getOrCreateProfile(targetUserId); + } + + async updateSkillsAdmin(targetUserId: string, dto: UpdateSkillsDto) { + return this.updateSkills(targetUserId, dto); + } + + async addExperienceAdmin(targetUserId: string, dto: CreateExperienceDto) { + return this.addExperience(targetUserId, dto); + } + + async deleteExperienceAdmin(targetUserId: string, experienceId: string) { + await this.verifyOwnership(targetUserId, 'expertExperience', experienceId, true); + await this.prisma.expertExperience.delete({ where: { id: experienceId } }); + } + + async addLanguageAdmin(targetUserId: string, dto: CreateLanguageDto) { + return this.addLanguage(targetUserId, dto); + } + + async deleteLanguageAdmin(targetUserId: string, languageId: string) { + await this.verifyOwnership(targetUserId, 'expertLanguage', languageId, true); + await this.prisma.expertLanguage.delete({ where: { id: languageId } }); + } + + async addProjectAdmin(targetUserId: string, dto: CreateProjectDto) { + return this.addProject(targetUserId, dto); + } + + async updateProjectAdmin(targetUserId: string, projectId: string, dto: UpdateProjectDto) { + await this.verifyOwnership(targetUserId, 'expertProject', projectId, true); + return this.prisma.expertProject.update({ + where: { id: projectId }, + data: { + ...(dto.role !== undefined && { role: dto.role }), + ...(dto.company !== undefined && { company: dto.company }), + ...(dto.industry !== undefined && { industry: dto.industry }), + ...(dto.fromMonth !== undefined && { fromMonth: dto.fromMonth }), + ...(dto.fromYear !== undefined && { fromYear: dto.fromYear }), + ...(dto.toMonth !== undefined && { toMonth: dto.toMonth }), + ...(dto.toYear !== undefined && { toYear: dto.toYear }), + ...(dto.isCurrent !== undefined && { isCurrent: dto.isCurrent }), + ...(dto.tasks !== undefined && { tasks: dto.tasks }), + }, + }); + } + + async deleteProjectAdmin(targetUserId: string, projectId: string) { + await this.verifyOwnership(targetUserId, 'expertProject', projectId, true); + await this.prisma.expertProject.delete({ where: { id: projectId } }); + } + + async addCertificationAdmin(targetUserId: string, dto: CreateCertificationDto) { + return this.addCertification(targetUserId, dto); + } + + async updateCertificationAdmin( + targetUserId: string, + certificationId: string, + dto: UpdateCertificationDto, + ) { + await this.verifyOwnership(targetUserId, 'expertCertification', certificationId, true); + return this.prisma.expertCertification.update({ + where: { id: certificationId }, + data: { + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.issuingBody !== undefined && { issuingBody: dto.issuingBody }), + ...(dto.issueYear !== undefined && { issueYear: dto.issueYear }), + ...(dto.website !== undefined && { website: dto.website }), + }, + }); + } + + async deleteCertificationAdmin(targetUserId: string, certificationId: string) { + await this.verifyOwnership(targetUserId, 'expertCertification', certificationId, true); + await this.prisma.expertCertification.delete({ where: { id: certificationId } }); + } } diff --git a/packages/core-service/src/core/profile-access/profile-access.controller.ts b/packages/core-service/src/core/profile-access/profile-access.controller.ts new file mode 100644 index 0000000..6f9d967 --- /dev/null +++ b/packages/core-service/src/core/profile-access/profile-access.controller.ts @@ -0,0 +1,119 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { IsString, IsBoolean, IsOptional, MaxLength } from 'class-validator'; +import { ProfileAccessService } from './profile-access.service'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { RolesGuard } from '../../common/guards/roles.guard'; + +class CreateGroupDto { + @IsString() + @MaxLength(100) + name!: string; + + @IsOptional() + @IsString() + description?: string | null; + + @IsBoolean() + canView!: boolean; + + @IsBoolean() + canExport!: boolean; + + @IsBoolean() + canEdit!: boolean; +} + +class UpdateGroupDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string | null; + + @IsOptional() + @IsBoolean() + canView?: boolean; + + @IsOptional() + @IsBoolean() + canExport?: boolean; + + @IsOptional() + @IsBoolean() + canEdit?: boolean; +} + +class AddMemberDto { + @IsString() + userId!: string; +} + +@ApiTags('Profilzugriff') +@ApiBearerAuth('access-token') +@Roles('PLATFORM_ADMIN') +@UseGuards(RolesGuard) +@Controller('profile-access') +export class ProfileAccessController { + constructor(private readonly profileAccessService: ProfileAccessService) {} + + @Get('groups') + getAllGroups() { + return this.profileAccessService.getAllGroups(); + } + + @Post('groups') + createGroup(@Body() dto: CreateGroupDto) { + return this.profileAccessService.createGroup(dto); + } + + @Patch('groups/:id') + updateGroup(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateGroupDto) { + return this.profileAccessService.updateGroup(id, dto); + } + + @Delete('groups/:id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteGroup(@Param('id', ParseUUIDPipe) id: string) { + return this.profileAccessService.deleteGroup(id); + } + + @Get('groups/:id/members') + getGroupMembers(@Param('id', ParseUUIDPipe) id: string) { + return this.profileAccessService.getGroupMembers(id); + } + + @Post('groups/:id/members') + addMember(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AddMemberDto) { + return this.profileAccessService.addMember(id, dto.userId); + } + + @Delete('groups/:id/members/:userId') + @HttpCode(HttpStatus.NO_CONTENT) + removeMember( + @Param('id', ParseUUIDPipe) id: string, + @Param('userId', ParseUUIDPipe) userId: string, + ) { + return this.profileAccessService.removeMember(id, userId); + } + + @Get('users') + getAllUsersForPicker() { + return this.profileAccessService.getAllUsersForPicker(); + } +} diff --git a/packages/core-service/src/core/profile-access/profile-access.module.ts b/packages/core-service/src/core/profile-access/profile-access.module.ts new file mode 100644 index 0000000..97fbb55 --- /dev/null +++ b/packages/core-service/src/core/profile-access/profile-access.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProfileAccessController } from './profile-access.controller'; +import { ProfileAccessService } from './profile-access.service'; + +@Module({ + controllers: [ProfileAccessController], + providers: [ProfileAccessService], + exports: [ProfileAccessService], +}) +export class ProfileAccessModule {} diff --git a/packages/core-service/src/core/profile-access/profile-access.service.ts b/packages/core-service/src/core/profile-access/profile-access.service.ts new file mode 100644 index 0000000..125d2ac --- /dev/null +++ b/packages/core-service/src/core/profile-access/profile-access.service.ts @@ -0,0 +1,200 @@ +import { Injectable, NotFoundException, ConflictException, ForbiddenException, Logger } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface ProfileAccessGroupDto { + name: string; + description?: string | null; + canView: boolean; + canExport: boolean; + canEdit: boolean; +} + +@Injectable() +export class ProfileAccessService { + private readonly logger = new Logger(ProfileAccessService.name); + + constructor(private readonly prisma: PrismaService) {} + + async getAllGroups() { + const groups = await this.prisma.profileAccessGroup.findMany({ + include: { _count: { select: { members: true } } }, + orderBy: { createdAt: 'asc' }, + }); + return groups.map((g) => ({ + id: g.id, + name: g.name, + description: g.description, + canView: g.canView, + canExport: g.canExport, + canEdit: g.canEdit, + memberCount: g._count.members, + createdAt: g.createdAt, + })); + } + + async createGroup(dto: ProfileAccessGroupDto) { + return this.prisma.profileAccessGroup.create({ + data: { + name: dto.name, + description: dto.description ?? null, + canView: dto.canView, + canExport: dto.canExport, + canEdit: dto.canEdit, + }, + }); + } + + async updateGroup(id: string, dto: Partial) { + await this.findGroupOrThrow(id); + return this.prisma.profileAccessGroup.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.canView !== undefined && { canView: dto.canView }), + ...(dto.canExport !== undefined && { canExport: dto.canExport }), + ...(dto.canEdit !== undefined && { canEdit: dto.canEdit }), + }, + }); + } + + async deleteGroup(id: string) { + await this.findGroupOrThrow(id); + await this.prisma.profileAccessGroup.delete({ where: { id } }); + } + + async getGroupMembers(groupId: string) { + await this.findGroupOrThrow(groupId); + const members = await this.prisma.profileAccessGroupMember.findMany({ + where: { groupId }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + jobTitle: true, + department: true, + avatar: true, + isActive: true, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + return members.map((m) => ({ memberId: m.id, ...m.user })); + } + + async addMember(groupId: string, userId: string) { + await this.findGroupOrThrow(groupId); + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new NotFoundException('Benutzer nicht gefunden'); + try { + await this.prisma.profileAccessGroupMember.create({ data: { groupId, userId } }); + } catch { + throw new ConflictException('Benutzer ist bereits Mitglied dieser Gruppe'); + } + } + + async removeMember(groupId: string, userId: string) { + const member = await this.prisma.profileAccessGroupMember.findUnique({ + where: { groupId_userId: { groupId, userId } }, + }); + if (!member) throw new NotFoundException('Mitglied nicht gefunden'); + await this.prisma.profileAccessGroupMember.delete({ where: { id: member.id } }); + } + + /** + * Prüft ob ein User Zugriff auf alle Profile hat. + * PLATFORM_ADMIN hat immer Zugriff. + * Sonstige User müssen in einer Gruppe mit der jeweiligen Berechtigung sein. + */ + async checkAccess( + userId: string, + permission: 'canView' | 'canExport' | 'canEdit', + ): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { role: true }, + }); + if (!user) return false; + if (user.role === 'PLATFORM_ADMIN') return true; + + const membership = await this.prisma.profileAccessGroupMember.findFirst({ + where: { + userId, + group: { [permission]: true }, + }, + }); + return membership !== null; + } + + async assertAccess( + userId: string, + permission: 'canView' | 'canExport' | 'canEdit', + ): Promise { + const hasAccess = await this.checkAccess(userId, permission); + if (!hasAccess) throw new ForbiddenException('Keine Berechtigung für diesen Zugriff'); + } + + async getAllUsersWithProfileSummary() { + const users = await this.prisma.user.findMany({ + where: { isActive: true }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + jobTitle: true, + department: true, + avatar: true, + expertProfile: { + select: { + id: true, + skills: true, + _count: { select: { projects: true, certifications: true, experiences: true } }, + }, + }, + }, + orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], + }); + + return users.map((u) => ({ + id: u.id, + firstName: u.firstName, + lastName: u.lastName, + email: u.email, + jobTitle: u.jobTitle, + department: u.department, + avatar: u.avatar, + hasProfile: u.expertProfile !== null, + profileId: u.expertProfile?.id ?? null, + skills: u.expertProfile?.skills ?? [], + projectCount: u.expertProfile?._count.projects ?? 0, + certificationCount: u.expertProfile?._count.certifications ?? 0, + experienceCount: u.expertProfile?._count.experiences ?? 0, + })); + } + + async getAllUsersForPicker() { + return this.prisma.user.findMany({ + where: { isActive: true }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + jobTitle: true, + department: true, + }, + orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], + }); + } + + private async findGroupOrThrow(id: string) { + const group = await this.prisma.profileAccessGroup.findUnique({ where: { id } }); + if (!group) throw new NotFoundException('Gruppe nicht gefunden'); + return group; + } +} diff --git a/packages/frontend/src/admin/AdminLayout.tsx b/packages/frontend/src/admin/AdminLayout.tsx index 5de8f81..ab1d938 100644 --- a/packages/frontend/src/admin/AdminLayout.tsx +++ b/packages/frontend/src/admin/AdminLayout.tsx @@ -9,6 +9,7 @@ const tabs = [ { to: '/admin/company', label: 'Firmendaten' }, { to: '/admin/events', label: 'Events' }, { to: '/admin/ssl', label: 'SSL / Domain' }, + { to: '/admin/profile-access', label: 'Profilzugriff' }, ]; export function AdminLayout() { diff --git a/packages/frontend/src/admin/AdminProfileAccessPage.tsx b/packages/frontend/src/admin/AdminProfileAccessPage.tsx new file mode 100644 index 0000000..e720ced --- /dev/null +++ b/packages/frontend/src/admin/AdminProfileAccessPage.tsx @@ -0,0 +1,477 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import api from '../api/client'; + +// ============================================================ +// Types +// ============================================================ +interface ProfileAccessGroup { + id: string; + name: string; + description: string | null; + canView: boolean; + canExport: boolean; + canEdit: boolean; + memberCount: number; + createdAt: string; +} + +interface GroupMember { + memberId: string; + id: string; + firstName: string; + lastName: string; + email: string; + jobTitle: string | null; + department: string | null; + avatar: string | null; +} + +interface UserForPicker { + id: string; + firstName: string; + lastName: string; + email: string; + jobTitle: string | null; + department: string | null; +} + +interface UserWithProfile { + id: string; + firstName: string; + lastName: string; + email: string; + jobTitle: string | null; + department: string | null; + avatar: string | null; + hasProfile: boolean; + profileId: string | null; + skills: string[]; + projectCount: number; + certificationCount: number; + experienceCount: number; +} + +// ============================================================ +// Styles (inline) +// ============================================================ +const cardStyle: React.CSSProperties = { + background: 'var(--color-bg-card)', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-md)', + padding: '1.25rem', + marginBottom: '1rem', +}; + +const badgeStyle = (active: boolean, color: string): React.CSSProperties => ({ + display: 'inline-block', + padding: '2px 8px', + borderRadius: '999px', + fontSize: '0.7rem', + fontWeight: 600, + background: active ? color + '22' : 'var(--color-bg-hover)', + color: active ? color : 'var(--color-text-muted)', + border: `1px solid ${active ? color + '44' : 'transparent'}`, +}); + +const btnPrimary: React.CSSProperties = { + padding: '0.5rem 1.25rem', + background: 'var(--color-primary)', + color: 'white', + border: 'none', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + fontWeight: 600, + cursor: 'pointer', +}; + +const btnSecondary: React.CSSProperties = { + padding: '0.4rem 0.875rem', + background: 'transparent', + color: 'var(--color-text)', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.8rem', + fontWeight: 500, + cursor: 'pointer', +}; + +const btnDanger: React.CSSProperties = { + ...btnSecondary, + color: 'var(--color-danger, #dc2626)', + borderColor: 'var(--color-danger, #dc2626)', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '0.5rem 0.75rem', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + background: 'var(--color-bg)', + color: 'var(--color-text)', + boxSizing: 'border-box', +}; + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: '0.75rem', + fontWeight: 600, + color: 'var(--color-text-secondary)', + marginBottom: '0.25rem', + textTransform: 'uppercase', + letterSpacing: '0.04em', +}; + +// ============================================================ +// GroupDialog +// ============================================================ +interface GroupFormData { name: string; description: string; canView: boolean; canExport: boolean; canEdit: boolean; } +const emptyGroupForm: GroupFormData = { name: '', description: '', canView: true, canExport: false, canEdit: false }; + +function GroupDialog({ group, onClose }: { group: ProfileAccessGroup | null; onClose: () => void }) { + const qc = useQueryClient(); + const [form, setForm] = useState( + group ? { name: group.name, description: group.description ?? '', canView: group.canView, canExport: group.canExport, canEdit: group.canEdit } : emptyGroupForm + ); + + const mutation = useMutation({ + mutationFn: (data: GroupFormData) => + group + ? api.patch(`/profile-access/groups/${group.id}`, data).then((r) => r.data) + : api.post('/profile-access/groups', data).then((r) => r.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['profile-access-groups'] }); + onClose(); + }, + }); + + const overlayStyle: React.CSSProperties = { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 1000, + display: 'flex', alignItems: 'center', justifyContent: 'center', + }; + const dialogStyle: React.CSSProperties = { + background: 'var(--color-bg-card)', borderRadius: 'var(--radius-md)', + boxShadow: 'var(--shadow-lg)', padding: '1.5rem', width: 420, maxWidth: '95vw', + }; + + return ( +
+
e.stopPropagation()}> +

+ {group ? 'Gruppe bearbeiten' : 'Neue Gruppe'} +

+ +
+ + setForm((p) => ({ ...p, name: e.target.value }))} placeholder="z.B. HR-Team" /> +
+
+ + setForm((p) => ({ ...p, description: e.target.value }))} placeholder="Optionale Beschreibung" /> +
+ +
+ + {(['canView', 'canExport', 'canEdit'] as const).map((key) => ( + + ))} +
+ +
+ + +
+ {mutation.isError &&

Fehler beim Speichern

} +
+
+ ); +} + +// ============================================================ +// GroupCard +// ============================================================ +function GroupCard({ group, allUsers }: { group: ProfileAccessGroup; allUsers: UserForPicker[] }) { + const qc = useQueryClient(); + const [expanded, setExpanded] = useState(false); + const [editing, setEditing] = useState(false); + const [pickerUserId, setPickerUserId] = useState(''); + + const { data: members = [] } = useQuery({ + queryKey: ['profile-access-group-members', group.id], + queryFn: () => api.get(`/profile-access/groups/${group.id}/members`).then((r) => r.data), + enabled: expanded, + }); + + const deleteMutation = useMutation({ + mutationFn: () => api.delete(`/profile-access/groups/${group.id}`), + onSuccess: () => void qc.invalidateQueries({ queryKey: ['profile-access-groups'] }), + }); + + const addMemberMutation = useMutation({ + mutationFn: (userId: string) => api.post(`/profile-access/groups/${group.id}/members`, { userId }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['profile-access-group-members', group.id] }); + void qc.invalidateQueries({ queryKey: ['profile-access-groups'] }); + setPickerUserId(''); + }, + }); + + const removeMemberMutation = useMutation({ + mutationFn: (userId: string) => api.delete(`/profile-access/groups/${group.id}/members/${userId}`), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['profile-access-group-members', group.id] }); + void qc.invalidateQueries({ queryKey: ['profile-access-groups'] }); + }, + }); + + const memberUserIds = new Set(members.map((m) => m.id)); + const availableUsers = allUsers.filter((u) => !memberUserIds.has(u.id)); + + return ( + <> + {editing && setEditing(false)} />} +
+
+
+
+ {group.name} + Ansehen + Exportieren + Bearbeiten +
+ {group.description &&

{group.description}

} + {group.memberCount} Mitglied{group.memberCount !== 1 ? 'er' : ''} +
+
+ + + +
+
+ + {expanded && ( +
+ {members.length === 0 ? ( +

Keine Mitglieder

+ ) : ( +
+ {members.map((m) => ( + + {m.firstName} {m.lastName} + + + ))} +
+ )} + {availableUsers.length > 0 && ( +
+ + +
+ )} +
+ )} +
+ + ); +} + +// ============================================================ +// ProfileStatusBadge +// ============================================================ +function ProfileStatus({ user }: { user: UserWithProfile }) { + if (!user.hasProfile) return Kein Profil; + const complete = user.projectCount > 0 && user.skills.length > 0; + return ( + + {complete ? '✓ Vollständig' : '⚠ Unvollständig'} + + ); +} + +// ============================================================ +// Main Page +// ============================================================ +export function AdminProfileAccessPage() { + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState<'groups' | 'profiles'>('groups'); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [profileSearch, setProfileSearch] = useState(''); + + const { data: groups = [], isLoading: groupsLoading } = useQuery({ + queryKey: ['profile-access-groups'], + queryFn: () => api.get('/profile-access/groups').then((r) => r.data), + }); + + const { data: allUsers = [] } = useQuery({ + queryKey: ['profile-access-users'], + queryFn: () => api.get('/profile-access/users').then((r) => r.data), + }); + + const { data: profileUsers = [], isLoading: profilesLoading } = useQuery({ + queryKey: ['expert-profile-admin-users'], + queryFn: () => api.get('/expert-profile/admin/users').then((r) => r.data), + enabled: activeTab === 'profiles', + }); + + const tabStyle = (active: boolean): React.CSSProperties => ({ + padding: '0.5rem 1rem', + fontSize: '0.875rem', + fontWeight: 600, + cursor: 'pointer', + border: 'none', + background: 'none', + borderBottom: `2px solid ${active ? 'var(--color-primary)' : 'transparent'}`, + color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)', + }); + + const filteredProfiles = profileUsers.filter((u) => { + const q = profileSearch.toLowerCase(); + return ( + !q || + u.firstName.toLowerCase().includes(q) || + u.lastName.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + (u.department ?? '').toLowerCase().includes(q) + ); + }); + + const handleDownload = async (userId: string, format: 'pdf' | 'docx', name: string) => { + const url = `/api/v1/expert-profile/admin/users/${userId}/export/${format}`; + const res = await api.get(url, { responseType: 'blob' }); + const blob = new Blob([res.data as BlobPart]); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${name}_CV.${format}`; + link.click(); + URL.revokeObjectURL(link.href); + }; + + return ( +
+ {showCreateDialog && setShowCreateDialog(false)} />} + +
+ + +
+ + {/* ── Gruppen-Tab ── */} + {activeTab === 'groups' && ( +
+
+
+

Berechtigungsgruppen

+

+ Gruppen mit Zugriff auf alle Expertenprofile verwalten +

+
+ +
+ + {groupsLoading ? ( +

Lädt…

+ ) : groups.length === 0 ? ( +
+

Noch keine Gruppen angelegt.

+

Erstelle eine Gruppe, um Benutzern Zugriff auf alle Expertenprofile zu gewähren.

+
+ ) : ( + groups.map((g) => ) + )} +
+ )} + + {/* ── Profile-Tab ── */} + {activeTab === 'profiles' && ( +
+
+

Alle Expertenprofile

+

+ Ansehen, exportieren und bearbeiten +

+ setProfileSearch(e.target.value)} + /> +
+ + {profilesLoading ? ( +

Lädt…

+ ) : ( +
+ + + + {['Name', 'Abteilung', 'Profil-Status', 'Aktionen'].map((h) => ( + + ))} + + + + {filteredProfiles.map((u, idx) => ( + + + + + + + ))} + {filteredProfiles.length === 0 && ( + + )} + +
{h}
+
+ {u.avatar ? ( + + ) : ( +
+ {u.firstName[0]}{u.lastName[0]} +
+ )} +
+
{u.firstName} {u.lastName}
+ {u.jobTitle &&
{u.jobTitle}
} +
+
+
+ {u.department ?? '—'} + + + +
+ + {u.hasProfile && ( + <> + + + + )} +
+
Keine Ergebnisse
+
+ )} +
+ )} +
+ ); +} diff --git a/packages/frontend/src/admin/AdminProfileDetailPage.tsx b/packages/frontend/src/admin/AdminProfileDetailPage.tsx new file mode 100644 index 0000000..b3e0dee --- /dev/null +++ b/packages/frontend/src/admin/AdminProfileDetailPage.tsx @@ -0,0 +1,100 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import api from '../api/client'; +import { ExpertProfileTab } from '../profile/ExpertProfileTab'; + +interface UserInfo { + id: string; + firstName: string; + lastName: string; + email: string; + jobTitle?: string | null; + department?: string | null; + avatar?: string | null; +} + +export function AdminProfileDetailPage() { + const { userId } = useParams<{ userId: string }>(); + const navigate = useNavigate(); + const [user, setUser] = useState(null); + + useEffect(() => { + if (!userId) return; + api + .get('/expert-profile/admin/users') + .then(({ data }) => { + const found = data.find((u) => u.id === userId); + if (found) setUser(found); + }) + .catch(() => { + // user info is optional — profile still renders + }); + }, [userId]); + + if (!userId) return null; + + const apiBase = `/expert-profile/admin/users/${userId}`; + + return ( +
+ + + {user && ( +
+ {user.avatar ? ( + {`${user.firstName} + ) : ( +
+ {user.firstName[0]}{user.lastName[0]} +
+ )} +
+

+ {user.firstName} {user.lastName} +

+
+ {[user.jobTitle, user.department].filter(Boolean).join(' · ')} +
+
{user.email}
+
+
+ )} + + +
+ ); +} diff --git a/packages/frontend/src/profile/ExpertProfileTab.tsx b/packages/frontend/src/profile/ExpertProfileTab.tsx index a8b0569..e9e5e93 100644 --- a/packages/frontend/src/profile/ExpertProfileTab.tsx +++ b/packages/frontend/src/profile/ExpertProfileTab.tsx @@ -63,7 +63,11 @@ interface ExpertProfile { export type { ExpertExperience, ExpertLanguage, ExpertProject, ExpertCertification, AttachmentMeta }; -export function ExpertProfileTab() { +interface ExpertProfileTabProps { + apiBase?: string; +} + +export function ExpertProfileTab({ apiBase = '/expert-profile/me' }: ExpertProfileTabProps = {}) { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -71,7 +75,7 @@ export function ExpertProfileTab() { const loadProfile = useCallback(async () => { try { - const { data } = await api.get('/expert-profile/me'); + const { data } = await api.get(apiBase); setProfile(data); setError(''); } catch { @@ -79,7 +83,7 @@ export function ExpertProfileTab() { } finally { setLoading(false); } - }, []); + }, [apiBase]); useEffect(() => { loadProfile(); @@ -88,7 +92,7 @@ export function ExpertProfileTab() { const handleExport = async (format: 'pdf' | 'docx') => { setExporting(true); try { - const response = await api.get(`/expert-profile/me/export/${format}`, { + const response = await api.get(`${apiBase}/export/${format}`, { responseType: 'blob', }); const blob = response.data as Blob; @@ -145,12 +149,12 @@ export function ExpertProfileTab() { {/* Skills + Sprachen nebeneinander */}
- - + +
- - - + + + ); diff --git a/packages/frontend/src/profile/sections/CertificationModal.tsx b/packages/frontend/src/profile/sections/CertificationModal.tsx index a642c99..e1a68b8 100644 --- a/packages/frontend/src/profile/sections/CertificationModal.tsx +++ b/packages/frontend/src/profile/sections/CertificationModal.tsx @@ -9,12 +9,13 @@ interface CertificationModalProps { onClose: () => void; onSave: () => Promise; certification: ExpertCertification | null; + apiBase?: string; } const currentYear = new Date().getFullYear(); const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i); -export function CertificationModal({ isOpen, onClose, onSave, certification }: CertificationModalProps) { +export function CertificationModal({ isOpen, onClose, onSave, certification, apiBase = '/expert-profile/me' }: CertificationModalProps) { const [title, setTitle] = useState(''); const [issuingBody, setIssuingBody] = useState(''); const [website, setWebsite] = useState(''); @@ -53,9 +54,9 @@ export function CertificationModal({ isOpen, onClose, onSave, certification }: C try { if (certification) { - await api.patch(`/expert-profile/me/certifications/${certification.id}`, payload); + await api.patch(`${apiBase}/certifications/${certification.id}`, payload); } else { - await api.post('/expert-profile/me/certifications', payload); + await api.post('${apiBase}/certifications', payload); } await onSave(); } catch (err: unknown) { diff --git a/packages/frontend/src/profile/sections/CertificationsSection.tsx b/packages/frontend/src/profile/sections/CertificationsSection.tsx index 5344d97..72b148b 100644 --- a/packages/frontend/src/profile/sections/CertificationsSection.tsx +++ b/packages/frontend/src/profile/sections/CertificationsSection.tsx @@ -7,9 +7,10 @@ import api from '../../api/client'; interface CertificationsSectionProps { certifications: ExpertCertification[]; onUpdate: () => Promise; + apiBase?: string; } -export function CertificationsSection({ certifications, onUpdate }: CertificationsSectionProps) { +export function CertificationsSection({ certifications, onUpdate, apiBase = '/expert-profile/me' }: CertificationsSectionProps) { const [modalOpen, setModalOpen] = useState(false); const [editCert, setEditCert] = useState(null); const [loading, setLoading] = useState(false); @@ -29,7 +30,7 @@ export function CertificationsSection({ certifications, onUpdate }: Certificatio setLoading(true); setError(''); try { - await api.delete(`/expert-profile/me/certifications/${id}`); + await api.delete(`${apiBase}/certifications/${id}`); await onUpdate(); } catch { setError('Fehler beim Löschen'); @@ -100,6 +101,7 @@ export function CertificationsSection({ certifications, onUpdate }: Certificatio onClose={handleModalClose} onSave={handleModalSave} certification={editCert} + apiBase={apiBase} /> ); diff --git a/packages/frontend/src/profile/sections/ExperienceSection.tsx b/packages/frontend/src/profile/sections/ExperienceSection.tsx index e66b1a4..b2a9736 100644 --- a/packages/frontend/src/profile/sections/ExperienceSection.tsx +++ b/packages/frontend/src/profile/sections/ExperienceSection.tsx @@ -6,9 +6,10 @@ import styles from '../ExpertProfileTab.module.css'; interface ExperienceSectionProps { experiences: ExpertExperience[]; onUpdate: () => Promise; + apiBase?: string; } -export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionProps) { +export function ExperienceSection({ experiences, onUpdate, apiBase = '/expert-profile/me' }: ExperienceSectionProps) { const [area, setArea] = useState(''); const [years, setYears] = useState(''); const [level, setLevel] = useState(''); @@ -22,7 +23,7 @@ export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionPr setLoading(true); setError(''); try { - await api.post('/expert-profile/me/experiences', { + await api.post(`${apiBase}/experiences`, { area: area.trim(), years: parseInt(years, 10), ...(level && { level }), @@ -42,7 +43,7 @@ export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionPr setLoading(true); setError(''); try { - await api.delete(`/expert-profile/me/experiences/${id}`); + await api.delete(`${apiBase}/experiences/${id}`); await onUpdate(); } catch { setError('Fehler beim Löschen'); diff --git a/packages/frontend/src/profile/sections/LanguagesSection.tsx b/packages/frontend/src/profile/sections/LanguagesSection.tsx index baf722b..ab2b86c 100644 --- a/packages/frontend/src/profile/sections/LanguagesSection.tsx +++ b/packages/frontend/src/profile/sections/LanguagesSection.tsx @@ -6,11 +6,12 @@ import styles from '../ExpertProfileTab.module.css'; interface LanguagesSectionProps { languages: ExpertLanguage[]; onUpdate: () => Promise; + apiBase?: string; } const LANGUAGE_LEVELS = ['Muttersprache', 'Verhandlungssicher', 'Fließend', 'Gut']; -export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps) { +export function LanguagesSection({ languages, onUpdate, apiBase = '/expert-profile/me' }: LanguagesSectionProps) { const [language, setLanguage] = useState(''); const [level, setLevel] = useState(''); const [loading, setLoading] = useState(false); @@ -23,7 +24,7 @@ export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps) setLoading(true); setError(''); try { - await api.post('/expert-profile/me/languages', { + await api.post(`${apiBase}/languages`, { language: language.trim(), level, }); @@ -41,7 +42,7 @@ export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps) setLoading(true); setError(''); try { - await api.delete(`/expert-profile/me/languages/${id}`); + await api.delete(`${apiBase}/languages/${id}`); await onUpdate(); } catch { setError('Fehler beim Löschen'); diff --git a/packages/frontend/src/profile/sections/ProjectModal.tsx b/packages/frontend/src/profile/sections/ProjectModal.tsx index c10d286..213876f 100644 --- a/packages/frontend/src/profile/sections/ProjectModal.tsx +++ b/packages/frontend/src/profile/sections/ProjectModal.tsx @@ -9,6 +9,7 @@ interface ProjectModalProps { onClose: () => void; onSave: () => Promise; project: ExpertProject | null; + apiBase?: string; } const MONTHS = [ @@ -327,7 +328,7 @@ function BulletEditor({ ); } -export function ProjectModal({ isOpen, onClose, onSave, project }: ProjectModalProps) { +export function ProjectModal({ isOpen, onClose, onSave, project, apiBase = '/expert-profile/me' }: ProjectModalProps) { const [fromMonth, setFromMonth] = useState(1); const [fromYear, setFromYear] = useState(currentYear); const [toMonth, setToMonth] = useState(1); @@ -389,9 +390,9 @@ export function ProjectModal({ isOpen, onClose, onSave, project }: ProjectModalP try { if (project) { - await api.patch(`/expert-profile/me/projects/${project.id}`, payload); + await api.patch(`${apiBase}/projects/${project.id}`, payload); } else { - await api.post('/expert-profile/me/projects', payload); + await api.post(`${apiBase}/projects`, payload); } await onSave(); } catch (err: unknown) { diff --git a/packages/frontend/src/profile/sections/ProjectsSection.tsx b/packages/frontend/src/profile/sections/ProjectsSection.tsx index f52cd48..59c1263 100644 --- a/packages/frontend/src/profile/sections/ProjectsSection.tsx +++ b/packages/frontend/src/profile/sections/ProjectsSection.tsx @@ -68,6 +68,7 @@ function RichText({ text }: { text: string }) { interface ProjectsSectionProps { projects: ExpertProject[]; onUpdate: () => Promise; + apiBase?: string; } const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']; @@ -79,7 +80,7 @@ function formatPeriod(p: ExpertProject): string { return from; } -export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) { +export function ProjectsSection({ projects, onUpdate, apiBase = '/expert-profile/me' }: ProjectsSectionProps) { const [modalOpen, setModalOpen] = useState(false); const [editProject, setEditProject] = useState(null); const [loading, setLoading] = useState(false); @@ -99,7 +100,7 @@ export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) { setLoading(true); setError(''); try { - await api.delete(`/expert-profile/me/projects/${id}`); + await api.delete(`${apiBase}/projects/${id}`); await onUpdate(); } catch { setError('Fehler beim Löschen'); @@ -182,6 +183,7 @@ export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) { onClose={handleModalClose} onSave={handleModalSave} project={editProject} + apiBase={apiBase} /> ); diff --git a/packages/frontend/src/profile/sections/SkillsSection.tsx b/packages/frontend/src/profile/sections/SkillsSection.tsx index 9589110..939ae95 100644 --- a/packages/frontend/src/profile/sections/SkillsSection.tsx +++ b/packages/frontend/src/profile/sections/SkillsSection.tsx @@ -5,9 +5,10 @@ import styles from '../ExpertProfileTab.module.css'; interface SkillsSectionProps { skills: string[]; onUpdate: () => Promise; + apiBase?: string; } -export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) { +export function SkillsSection({ skills, onUpdate, apiBase = '/expert-profile/me' }: SkillsSectionProps) { const [newSkill, setNewSkill] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -23,7 +24,7 @@ export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) { setLoading(true); setError(''); try { - await api.patch('/expert-profile/me/skills', { skills: [...skills, skill] }); + await api.patch(`${apiBase}/skills`, { skills: [...skills, skill] }); setNewSkill(''); await onUpdate(); } catch { @@ -37,7 +38,7 @@ export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) { setLoading(true); setError(''); try { - await api.patch('/expert-profile/me/skills', { + await api.patch(`${apiBase}/skills`, { skills: skills.filter((s) => s !== skillToRemove), }); await onUpdate(); diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index bde6a39..2c561c6 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -13,6 +13,8 @@ import { AdminCustomizePage } from '../admin/AdminCustomizePage'; import { AdminEventsPage } from '../admin/AdminEventsPage'; import { AdminSslPage } from '../admin/AdminSslPage'; import { AdminCompanyPage } from '../admin/AdminCompanyPage'; +import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage'; +import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage'; import { ProfilePage } from '../profile/ProfilePage'; import { ContactsPage } from '../crm/contacts/ContactsPage'; import { ContactDetailPage } from '../crm/contacts/ContactDetailPage'; @@ -91,7 +93,10 @@ export function App() { } /> } /> } /> + } /> + {/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */} + } /> {/* Fallback */}