feat(core+frontend): Profilzugriff-Gruppen für Admin mit delegierten Berechtigungen

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-14 10:47:36 +01:00
parent f7736a6fca
commit c8b25321e7
21 changed files with 1300 additions and 32 deletions

View file

@ -57,10 +57,11 @@ model User {
// Relationen // Relationen
authProvider AuthProvider[] authProvider AuthProvider[]
tenantMemberships TenantMembership[] tenantMemberships TenantMembership[]
auditLogs AuditLog[] auditLogs AuditLog[]
expertProfile ExpertProfile? expertProfile ExpertProfile?
integrations UserIntegration[] integrations UserIntegration[]
profileAccessGroups ProfileAccessGroupMember[]
@@map("users") @@map("users")
} }
@ -90,6 +91,38 @@ model UserIntegration {
@@map("user_integrations") @@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 // AuthProvider - Authentifizierungs-Provider pro User
// -------------------------------------------------------- // --------------------------------------------------------

View file

@ -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;

View file

@ -12,6 +12,7 @@ import { TenantsModule } from './core/tenants/tenants.module';
import { ExpertProfileModule } from './core/expert-profile/expert-profile.module'; import { ExpertProfileModule } from './core/expert-profile/expert-profile.module';
import { SettingsModule } from './core/settings/settings.module'; import { SettingsModule } from './core/settings/settings.module';
import { IntegrationsModule } from './core/integrations/integrations.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 { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { validateConfig } from './config/env.validation'; import { validateConfig } from './config/env.validation';
@ -46,6 +47,7 @@ import { validateConfig } from './config/env.validation';
ExpertProfileModule, ExpertProfileModule,
SettingsModule, SettingsModule,
IntegrationsModule, IntegrationsModule,
ProfileAccessModule,
], ],
providers: [ providers: [
// Global Guards: Alle Routen sind standardmaessig geschuetzt // Global Guards: Alle Routen sind standardmaessig geschuetzt

View file

@ -16,6 +16,7 @@ import type { Response } from 'express';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ExpertProfileService } from './expert-profile.service'; import { ExpertProfileService } from './expert-profile.service';
import { ProfileExportService } from './profile-export.service'; import { ProfileExportService } from './profile-export.service';
import { ProfileAccessService } from '../profile-access/profile-access.service';
import { UpdateSkillsDto } from './dto/update-skills.dto'; import { UpdateSkillsDto } from './dto/update-skills.dto';
import { CreateExperienceDto } from './dto/create-experience.dto'; import { CreateExperienceDto } from './dto/create-experience.dto';
import { CreateLanguageDto } from './dto/create-language.dto'; import { CreateLanguageDto } from './dto/create-language.dto';
@ -32,6 +33,7 @@ export class ExpertProfileController {
constructor( constructor(
private readonly expertProfileService: ExpertProfileService, private readonly expertProfileService: ExpertProfileService,
private readonly profileExportService: ProfileExportService, private readonly profileExportService: ProfileExportService,
private readonly profileAccessService: ProfileAccessService,
) {} ) {}
// ============================================================ // ============================================================
@ -230,4 +232,190 @@ export class ExpertProfileController {
) { ) {
await this.expertProfileService.deleteAttachment(userId, id); 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);
}
} }

View file

@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { ExpertProfileController } from './expert-profile.controller'; import { ExpertProfileController } from './expert-profile.controller';
import { ExpertProfileService } from './expert-profile.service'; import { ExpertProfileService } from './expert-profile.service';
import { ProfileExportService } from './profile-export.service'; import { ProfileExportService } from './profile-export.service';
import { ProfileAccessModule } from '../profile-access/profile-access.module';
@Module({ @Module({
imports: [ProfileAccessModule],
controllers: [ExpertProfileController], controllers: [ExpertProfileController],
providers: [ExpertProfileService, ProfileExportService], providers: [ExpertProfileService, ProfileExportService],
exports: [ExpertProfileService], exports: [ExpertProfileService],

View file

@ -333,7 +333,10 @@ export class ExpertProfileService {
userId: string, userId: string,
model: 'expertExperience' | 'expertLanguage' | 'expertProject' | 'expertCertification', model: 'expertExperience' | 'expertLanguage' | 'expertProject' | 'expertCertification',
entityId: string, entityId: string,
skipCheck = false,
): Promise<void> { ): Promise<void> {
if (skipCheck) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const entity = await (this.prisma[model] as any).findUnique({ const entity = await (this.prisma[model] as any).findUnique({
where: { id: entityId }, where: { id: entityId },
@ -348,4 +351,87 @@ export class ExpertProfileService {
throw new ForbiddenException('Kein Zugriff auf diesen Eintrag'); 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 } });
}
} }

View file

@ -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();
}
}

View file

@ -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 {}

View file

@ -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<ProfileAccessGroupDto>) {
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<boolean> {
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<void> {
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;
}
}

View file

@ -9,6 +9,7 @@ const tabs = [
{ to: '/admin/company', label: 'Firmendaten' }, { to: '/admin/company', label: 'Firmendaten' },
{ to: '/admin/events', label: 'Events' }, { to: '/admin/events', label: 'Events' },
{ to: '/admin/ssl', label: 'SSL / Domain' }, { to: '/admin/ssl', label: 'SSL / Domain' },
{ to: '/admin/profile-access', label: 'Profilzugriff' },
]; ];
export function AdminLayout() { export function AdminLayout() {

View file

@ -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<GroupFormData>(
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 (
<div style={overlayStyle} onClick={onClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 1.25rem', fontSize: '1rem', fontWeight: 700 }}>
{group ? 'Gruppe bearbeiten' : 'Neue Gruppe'}
</h3>
<div style={{ marginBottom: '0.875rem' }}>
<label style={labelStyle}>Gruppenname *</label>
<input style={inputStyle} value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="z.B. HR-Team" />
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Beschreibung</label>
<input style={inputStyle} value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="Optionale Beschreibung" />
</div>
<div style={{ marginBottom: '1.25rem' }}>
<label style={labelStyle}>Berechtigungen</label>
{(['canView', 'canExport', 'canEdit'] as const).map((key) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem', cursor: 'pointer', fontSize: '0.875rem' }}>
<input type="checkbox" checked={form[key]} onChange={(e) => setForm((p) => ({ ...p, [key]: e.target.checked }))} />
{{ canView: 'Ansehen', canExport: 'Exportieren', canEdit: 'Bearbeiten' }[key]}
</label>
))}
</div>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button style={btnSecondary} onClick={onClose}>Abbrechen</button>
<button style={btnPrimary} disabled={!form.name.trim() || mutation.isPending} onClick={() => mutation.mutate(form)}>
{mutation.isPending ? 'Speichern…' : 'Speichern'}
</button>
</div>
{mutation.isError && <p style={{ color: 'var(--color-danger, #dc2626)', fontSize: '0.8rem', marginTop: '0.5rem' }}>Fehler beim Speichern</p>}
</div>
</div>
);
}
// ============================================================
// 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<GroupMember[]>({
queryKey: ['profile-access-group-members', group.id],
queryFn: () => api.get<GroupMember[]>(`/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 && <GroupDialog group={group} onClose={() => setEditing(false)} />}
<div style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
<span style={{ fontWeight: 700, fontSize: '0.9375rem' }}>{group.name}</span>
<span style={badgeStyle(group.canView, '#2563eb')}>Ansehen</span>
<span style={badgeStyle(group.canExport, '#7c3aed')}>Exportieren</span>
<span style={badgeStyle(group.canEdit, '#059669')}>Bearbeiten</span>
</div>
{group.description && <p style={{ margin: '0 0 0.25rem', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{group.description}</p>}
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{group.memberCount} Mitglied{group.memberCount !== 1 ? 'er' : ''}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button style={btnSecondary} onClick={() => setExpanded((v) => !v)}>{expanded ? 'Schließen' : 'Mitglieder'}</button>
<button style={btnSecondary} onClick={() => setEditing(true)}>Bearbeiten</button>
<button style={btnDanger} disabled={deleteMutation.isPending} onClick={() => { if (confirm(`Gruppe "${group.name}" löschen?`)) deleteMutation.mutate(); }}>Löschen</button>
</div>
</div>
{expanded && (
<div style={{ marginTop: '1rem', borderTop: '1px solid var(--color-border)', paddingTop: '0.875rem' }}>
{members.length === 0 ? (
<p style={{ margin: '0 0 0.75rem', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>Keine Mitglieder</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.75rem' }}>
{members.map((m) => (
<span key={m.memberId} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', background: 'var(--color-bg-hover)', border: '1px solid var(--color-border)', borderRadius: '999px', padding: '3px 10px', fontSize: '0.8rem' }}>
{m.firstName} {m.lastName}
<button onClick={() => removeMemberMutation.mutate(m.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: 0, lineHeight: 1 }}>×</button>
</span>
))}
</div>
)}
{availableUsers.length > 0 && (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<select value={pickerUserId} onChange={(e) => setPickerUserId(e.target.value)}
style={{ ...inputStyle, width: 'auto', flex: 1 }}>
<option value="">Benutzer hinzufügen</option>
{availableUsers.map((u) => (
<option key={u.id} value={u.id}>{u.firstName} {u.lastName} ({u.email})</option>
))}
</select>
<button style={btnPrimary} disabled={!pickerUserId || addMemberMutation.isPending}
onClick={() => pickerUserId && addMemberMutation.mutate(pickerUserId)}>
Hinzufügen
</button>
</div>
)}
</div>
)}
</div>
</>
);
}
// ============================================================
// ProfileStatusBadge
// ============================================================
function ProfileStatus({ user }: { user: UserWithProfile }) {
if (!user.hasProfile) return <span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Kein Profil</span>;
const complete = user.projectCount > 0 && user.skills.length > 0;
return (
<span style={{ fontSize: '0.75rem', fontWeight: 500, color: complete ? '#16a34a' : '#d97706' }}>
{complete ? '✓ Vollständig' : '⚠ Unvollständig'}
</span>
);
}
// ============================================================
// 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<ProfileAccessGroup[]>({
queryKey: ['profile-access-groups'],
queryFn: () => api.get<ProfileAccessGroup[]>('/profile-access/groups').then((r) => r.data),
});
const { data: allUsers = [] } = useQuery<UserForPicker[]>({
queryKey: ['profile-access-users'],
queryFn: () => api.get<UserForPicker[]>('/profile-access/users').then((r) => r.data),
});
const { data: profileUsers = [], isLoading: profilesLoading } = useQuery<UserWithProfile[]>({
queryKey: ['expert-profile-admin-users'],
queryFn: () => api.get<UserWithProfile[]>('/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 (
<div style={{ maxWidth: 900 }}>
{showCreateDialog && <GroupDialog group={null} onClose={() => setShowCreateDialog(false)} />}
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: '0.25rem' }}>
<button style={tabStyle(activeTab === 'groups')} onClick={() => setActiveTab('groups')}>Gruppen</button>
<button style={tabStyle(activeTab === 'profiles')} onClick={() => setActiveTab('profiles')}>Profile</button>
</div>
{/* ── Gruppen-Tab ── */}
{activeTab === 'groups' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>Berechtigungsgruppen</h2>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
Gruppen mit Zugriff auf alle Expertenprofile verwalten
</p>
</div>
<button style={btnPrimary} onClick={() => setShowCreateDialog(true)}>+ Neue Gruppe</button>
</div>
{groupsLoading ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Lädt</p>
) : groups.length === 0 ? (
<div style={{ ...cardStyle, textAlign: 'center', padding: '2.5rem', color: 'var(--color-text-secondary)' }}>
<p style={{ margin: 0 }}>Noch keine Gruppen angelegt.</p>
<p style={{ margin: '0.5rem 0 0', fontSize: '0.8125rem' }}>Erstelle eine Gruppe, um Benutzern Zugriff auf alle Expertenprofile zu gewähren.</p>
</div>
) : (
groups.map((g) => <GroupCard key={g.id} group={g} allUsers={allUsers} />)
)}
</div>
)}
{/* ── Profile-Tab ── */}
{activeTab === 'profiles' && (
<div>
<div style={{ marginBottom: '1.25rem' }}>
<h2 style={{ margin: '0 0 0.25rem', fontSize: '1rem', fontWeight: 700 }}>Alle Expertenprofile</h2>
<p style={{ margin: '0 0 0.875rem', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
Ansehen, exportieren und bearbeiten
</p>
<input
style={{ ...inputStyle, maxWidth: 320 }}
placeholder="Suchen nach Name, E-Mail, Abteilung…"
value={profileSearch}
onChange={(e) => setProfileSearch(e.target.value)}
/>
</div>
{profilesLoading ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Lädt</p>
) : (
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--color-bg-hover)', borderBottom: '1px solid var(--color-border)' }}>
{['Name', 'Abteilung', 'Profil-Status', 'Aktionen'].map((h) => (
<th key={h} style={{ padding: '0.625rem 1rem', textAlign: 'left', fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{filteredProfiles.map((u, idx) => (
<tr key={u.id} style={{ borderBottom: idx < filteredProfiles.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<td style={{ padding: '0.75rem 1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
{u.avatar ? (
<img src={u.avatar} alt="" style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
) : (
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontSize: '0.75rem', fontWeight: 700, flexShrink: 0 }}>
{u.firstName[0]}{u.lastName[0]}
</div>
)}
<div>
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>{u.firstName} {u.lastName}</div>
{u.jobTitle && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>{u.jobTitle}</div>}
</div>
</div>
</td>
<td style={{ padding: '0.75rem 1rem', fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
{u.department ?? '—'}
</td>
<td style={{ padding: '0.75rem 1rem' }}>
<ProfileStatus user={u} />
</td>
<td style={{ padding: '0.75rem 1rem' }}>
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
<button style={btnSecondary} title="Profil bearbeiten" onClick={() => navigate(`/admin/profiles/${u.id}`)}>Bearbeiten</button>
{u.hasProfile && (
<>
<button style={btnSecondary} title="PDF herunterladen" onClick={() => void handleDownload(u.id, 'pdf', `${u.firstName}_${u.lastName}`)}>PDF</button>
<button style={btnSecondary} title="Word herunterladen" onClick={() => void handleDownload(u.id, 'docx', `${u.firstName}_${u.lastName}`)}>Word</button>
</>
)}
</div>
</td>
</tr>
))}
{filteredProfiles.length === 0 && (
<tr><td colSpan={4} style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>Keine Ergebnisse</td></tr>
)}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -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<UserInfo | null>(null);
useEffect(() => {
if (!userId) return;
api
.get<UserInfo[]>('/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 (
<div style={{ padding: '1.5rem' }}>
<button
type="button"
onClick={() => navigate('/admin/profile-access')}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#2563eb',
fontSize: '0.875rem',
padding: '0 0 1rem 0',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
}}
>
Zurück zu Profilzugriff
</button>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}>
{user.avatar ? (
<img
src={user.avatar}
alt={`${user.firstName} ${user.lastName}`}
style={{ width: 56, height: 56, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<div
style={{
width: 56,
height: 56,
borderRadius: '50%',
background: '#e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
fontWeight: 600,
color: '#6b7280',
flexShrink: 0,
}}
>
{user.firstName[0]}{user.lastName[0]}
</div>
)}
<div>
<h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 700 }}>
{user.firstName} {user.lastName}
</h2>
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>
{[user.jobTitle, user.department].filter(Boolean).join(' · ')}
</div>
<div style={{ color: '#9ca3af', fontSize: '0.75rem' }}>{user.email}</div>
</div>
</div>
)}
<ExpertProfileTab apiBase={apiBase} />
</div>
);
}

View file

@ -63,7 +63,11 @@ interface ExpertProfile {
export type { ExpertExperience, ExpertLanguage, ExpertProject, ExpertCertification, AttachmentMeta }; 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<ExpertProfile | null>(null); const [profile, setProfile] = useState<ExpertProfile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -71,7 +75,7 @@ export function ExpertProfileTab() {
const loadProfile = useCallback(async () => { const loadProfile = useCallback(async () => {
try { try {
const { data } = await api.get<ExpertProfile>('/expert-profile/me'); const { data } = await api.get<ExpertProfile>(apiBase);
setProfile(data); setProfile(data);
setError(''); setError('');
} catch { } catch {
@ -79,7 +83,7 @@ export function ExpertProfileTab() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [apiBase]);
useEffect(() => { useEffect(() => {
loadProfile(); loadProfile();
@ -88,7 +92,7 @@ export function ExpertProfileTab() {
const handleExport = async (format: 'pdf' | 'docx') => { const handleExport = async (format: 'pdf' | 'docx') => {
setExporting(true); setExporting(true);
try { try {
const response = await api.get(`/expert-profile/me/export/${format}`, { const response = await api.get(`${apiBase}/export/${format}`, {
responseType: 'blob', responseType: 'blob',
}); });
const blob = response.data as Blob; const blob = response.data as Blob;
@ -145,12 +149,12 @@ export function ExpertProfileTab() {
{/* Skills + Sprachen nebeneinander */} {/* Skills + Sprachen nebeneinander */}
<div className={styles.twoColumnRow}> <div className={styles.twoColumnRow}>
<SkillsSection skills={profile.skills} onUpdate={loadProfile} /> <SkillsSection skills={profile.skills} onUpdate={loadProfile} apiBase={apiBase} />
<LanguagesSection languages={profile.languages} onUpdate={loadProfile} /> <LanguagesSection languages={profile.languages} onUpdate={loadProfile} apiBase={apiBase} />
</div> </div>
<ExperienceSection experiences={profile.experiences} onUpdate={loadProfile} /> <ExperienceSection experiences={profile.experiences} onUpdate={loadProfile} apiBase={apiBase} />
<ProjectsSection projects={profile.projects} onUpdate={loadProfile} /> <ProjectsSection projects={profile.projects} onUpdate={loadProfile} apiBase={apiBase} />
<CertificationsSection certifications={profile.certifications} onUpdate={loadProfile} /> <CertificationsSection certifications={profile.certifications} onUpdate={loadProfile} apiBase={apiBase} />
<AttachmentsSection attachments={profile.attachments} onUpdate={loadProfile} /> <AttachmentsSection attachments={profile.attachments} onUpdate={loadProfile} />
</div> </div>
); );

View file

@ -9,12 +9,13 @@ interface CertificationModalProps {
onClose: () => void; onClose: () => void;
onSave: () => Promise<void>; onSave: () => Promise<void>;
certification: ExpertCertification | null; certification: ExpertCertification | null;
apiBase?: string;
} }
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i); 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 [title, setTitle] = useState('');
const [issuingBody, setIssuingBody] = useState(''); const [issuingBody, setIssuingBody] = useState('');
const [website, setWebsite] = useState(''); const [website, setWebsite] = useState('');
@ -53,9 +54,9 @@ export function CertificationModal({ isOpen, onClose, onSave, certification }: C
try { try {
if (certification) { if (certification) {
await api.patch(`/expert-profile/me/certifications/${certification.id}`, payload); await api.patch(`${apiBase}/certifications/${certification.id}`, payload);
} else { } else {
await api.post('/expert-profile/me/certifications', payload); await api.post('${apiBase}/certifications', payload);
} }
await onSave(); await onSave();
} catch (err: unknown) { } catch (err: unknown) {

View file

@ -7,9 +7,10 @@ import api from '../../api/client';
interface CertificationsSectionProps { interface CertificationsSectionProps {
certifications: ExpertCertification[]; certifications: ExpertCertification[];
onUpdate: () => Promise<void>; onUpdate: () => Promise<void>;
apiBase?: string;
} }
export function CertificationsSection({ certifications, onUpdate }: CertificationsSectionProps) { export function CertificationsSection({ certifications, onUpdate, apiBase = '/expert-profile/me' }: CertificationsSectionProps) {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editCert, setEditCert] = useState<ExpertCertification | null>(null); const [editCert, setEditCert] = useState<ExpertCertification | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -29,7 +30,7 @@ export function CertificationsSection({ certifications, onUpdate }: Certificatio
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await api.delete(`/expert-profile/me/certifications/${id}`); await api.delete(`${apiBase}/certifications/${id}`);
await onUpdate(); await onUpdate();
} catch { } catch {
setError('Fehler beim Löschen'); setError('Fehler beim Löschen');
@ -100,6 +101,7 @@ export function CertificationsSection({ certifications, onUpdate }: Certificatio
onClose={handleModalClose} onClose={handleModalClose}
onSave={handleModalSave} onSave={handleModalSave}
certification={editCert} certification={editCert}
apiBase={apiBase}
/> />
</div> </div>
); );

View file

@ -6,9 +6,10 @@ import styles from '../ExpertProfileTab.module.css';
interface ExperienceSectionProps { interface ExperienceSectionProps {
experiences: ExpertExperience[]; experiences: ExpertExperience[];
onUpdate: () => Promise<void>; onUpdate: () => Promise<void>;
apiBase?: string;
} }
export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionProps) { export function ExperienceSection({ experiences, onUpdate, apiBase = '/expert-profile/me' }: ExperienceSectionProps) {
const [area, setArea] = useState(''); const [area, setArea] = useState('');
const [years, setYears] = useState(''); const [years, setYears] = useState('');
const [level, setLevel] = useState(''); const [level, setLevel] = useState('');
@ -22,7 +23,7 @@ export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionPr
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await api.post('/expert-profile/me/experiences', { await api.post(`${apiBase}/experiences`, {
area: area.trim(), area: area.trim(),
years: parseInt(years, 10), years: parseInt(years, 10),
...(level && { level }), ...(level && { level }),
@ -42,7 +43,7 @@ export function ExperienceSection({ experiences, onUpdate }: ExperienceSectionPr
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await api.delete(`/expert-profile/me/experiences/${id}`); await api.delete(`${apiBase}/experiences/${id}`);
await onUpdate(); await onUpdate();
} catch { } catch {
setError('Fehler beim Löschen'); setError('Fehler beim Löschen');

View file

@ -6,11 +6,12 @@ import styles from '../ExpertProfileTab.module.css';
interface LanguagesSectionProps { interface LanguagesSectionProps {
languages: ExpertLanguage[]; languages: ExpertLanguage[];
onUpdate: () => Promise<void>; onUpdate: () => Promise<void>;
apiBase?: string;
} }
const LANGUAGE_LEVELS = ['Muttersprache', 'Verhandlungssicher', 'Fließend', 'Gut']; 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 [language, setLanguage] = useState('');
const [level, setLevel] = useState(''); const [level, setLevel] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -23,7 +24,7 @@ export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps)
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await api.post('/expert-profile/me/languages', { await api.post(`${apiBase}/languages`, {
language: language.trim(), language: language.trim(),
level, level,
}); });
@ -41,7 +42,7 @@ export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps)
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await api.delete(`/expert-profile/me/languages/${id}`); await api.delete(`${apiBase}/languages/${id}`);
await onUpdate(); await onUpdate();
} catch { } catch {
setError('Fehler beim Löschen'); setError('Fehler beim Löschen');

View file

@ -9,6 +9,7 @@ interface ProjectModalProps {
onClose: () => void; onClose: () => void;
onSave: () => Promise<void>; onSave: () => Promise<void>;
project: ExpertProject | null; project: ExpertProject | null;
apiBase?: string;
} }
const MONTHS = [ 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 [fromMonth, setFromMonth] = useState(1);
const [fromYear, setFromYear] = useState(currentYear); const [fromYear, setFromYear] = useState(currentYear);
const [toMonth, setToMonth] = useState(1); const [toMonth, setToMonth] = useState(1);
@ -389,9 +390,9 @@ export function ProjectModal({ isOpen, onClose, onSave, project }: ProjectModalP
try { try {
if (project) { if (project) {
await api.patch(`/expert-profile/me/projects/${project.id}`, payload); await api.patch(`${apiBase}/projects/${project.id}`, payload);
} else { } else {
await api.post('/expert-profile/me/projects', payload); await api.post(`${apiBase}/projects`, payload);
} }
await onSave(); await onSave();
} catch (err: unknown) { } catch (err: unknown) {

View file

@ -68,6 +68,7 @@ function RichText({ text }: { text: string }) {
interface ProjectsSectionProps { interface ProjectsSectionProps {
projects: ExpertProject[]; projects: ExpertProject[];
onUpdate: () => Promise<void>; onUpdate: () => Promise<void>;
apiBase?: string;
} }
const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']; 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; return from;
} }
export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) { export function ProjectsSection({ projects, onUpdate, apiBase = '/expert-profile/me' }: ProjectsSectionProps) {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editProject, setEditProject] = useState<ExpertProject | null>(null); const [editProject, setEditProject] = useState<ExpertProject | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -99,7 +100,7 @@ export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await api.delete(`/expert-profile/me/projects/${id}`); await api.delete(`${apiBase}/projects/${id}`);
await onUpdate(); await onUpdate();
} catch { } catch {
setError('Fehler beim Löschen'); setError('Fehler beim Löschen');
@ -182,6 +183,7 @@ export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) {
onClose={handleModalClose} onClose={handleModalClose}
onSave={handleModalSave} onSave={handleModalSave}
project={editProject} project={editProject}
apiBase={apiBase}
/> />
</div> </div>
); );

View file

@ -5,9 +5,10 @@ import styles from '../ExpertProfileTab.module.css';
interface SkillsSectionProps { interface SkillsSectionProps {
skills: string[]; skills: string[];
onUpdate: () => Promise<void>; onUpdate: () => Promise<void>;
apiBase?: string;
} }
export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) { export function SkillsSection({ skills, onUpdate, apiBase = '/expert-profile/me' }: SkillsSectionProps) {
const [newSkill, setNewSkill] = useState(''); const [newSkill, setNewSkill] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -23,7 +24,7 @@ export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await api.patch('/expert-profile/me/skills', { skills: [...skills, skill] }); await api.patch(`${apiBase}/skills`, { skills: [...skills, skill] });
setNewSkill(''); setNewSkill('');
await onUpdate(); await onUpdate();
} catch { } catch {
@ -37,7 +38,7 @@ export function SkillsSection({ skills, onUpdate }: SkillsSectionProps) {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await api.patch('/expert-profile/me/skills', { await api.patch(`${apiBase}/skills`, {
skills: skills.filter((s) => s !== skillToRemove), skills: skills.filter((s) => s !== skillToRemove),
}); });
await onUpdate(); await onUpdate();

View file

@ -13,6 +13,8 @@ import { AdminCustomizePage } from '../admin/AdminCustomizePage';
import { AdminEventsPage } from '../admin/AdminEventsPage'; import { AdminEventsPage } from '../admin/AdminEventsPage';
import { AdminSslPage } from '../admin/AdminSslPage'; import { AdminSslPage } from '../admin/AdminSslPage';
import { AdminCompanyPage } from '../admin/AdminCompanyPage'; import { AdminCompanyPage } from '../admin/AdminCompanyPage';
import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage';
import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage';
import { ProfilePage } from '../profile/ProfilePage'; import { ProfilePage } from '../profile/ProfilePage';
import { ContactsPage } from '../crm/contacts/ContactsPage'; import { ContactsPage } from '../crm/contacts/ContactsPage';
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage'; import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
@ -91,7 +93,10 @@ export function App() {
<Route path="company" element={<AdminCompanyPage />} /> <Route path="company" element={<AdminCompanyPage />} />
<Route path="events" element={<AdminEventsPage />} /> <Route path="events" element={<AdminEventsPage />} />
<Route path="ssl" element={<AdminSslPage />} /> <Route path="ssl" element={<AdminSslPage />} />
<Route path="profile-access" element={<AdminProfileAccessPage />} />
</Route> </Route>
{/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */}
<Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} />
</Route> </Route>
{/* Fallback */} {/* Fallback */}