From e92de273fcc7dc5a1a97d53cfb57e5115a821c17 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 13 May 2026 17:17:42 +0200 Subject: [PATCH] fix feature --- .../controllers/gdpr.controller.ts | 63 +++++- .../src/application/gdpr/gdpr.module.ts | 4 + .../src/application/services/gdpr.service.ts | 64 ++++-- .../app/[locale]/dashboard/profile/page.tsx | 17 +- apps/frontend/messages/en.json | 29 ++- apps/frontend/messages/fr.json | 29 ++- .../components/profile/DeleteAccountTab.tsx | 199 ++++++++++++++++++ apps/frontend/src/lib/api/gdpr.ts | 49 ++++- apps/frontend/src/lib/api/index.ts | 4 +- 9 files changed, 430 insertions(+), 28 deletions(-) create mode 100644 apps/frontend/src/components/profile/DeleteAccountTab.tsx diff --git a/apps/backend/src/application/controllers/gdpr.controller.ts b/apps/backend/src/application/controllers/gdpr.controller.ts index ee37702..d9b7ef0 100644 --- a/apps/backend/src/application/controllers/gdpr.controller.ts +++ b/apps/backend/src/application/controllers/gdpr.controller.ts @@ -15,6 +15,8 @@ import { HttpStatus, Res, Req, + ForbiddenException, + BadRequestException, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { Response, Request } from 'express'; @@ -96,6 +98,24 @@ export class GDPRController { res.send(csv); } + /** + * Check if current user can self-delete their account + */ + @Get('can-self-delete') + @ApiOperation({ + summary: 'Check if account self-deletion is possible', + description: 'For managers: returns true only if another manager exists in the organization', + }) + @ApiResponse({ status: 200, description: 'Self-deletion eligibility check' }) + async canSelfDelete( + @CurrentUser() user: UserPayload + ): Promise<{ canSelfDelete: boolean; otherManagersCount: number }> { + if (user.role !== 'MANAGER') { + return { canSelfDelete: true, otherManagersCount: 0 }; + } + return this.gdprService.canManagerSelfDelete(user.id, user.organizationId); + } + /** * Delete user data (GDPR Right to Erasure) */ @@ -107,20 +127,57 @@ export class GDPRController { }) @ApiResponse({ status: 204, - description: 'Account deletion initiated', + description: 'Account deleted', }) async deleteAccount( @CurrentUser() user: UserPayload, @Body() body: { reason?: string; confirmEmail: string } ): Promise { - // Verify email confirmation (security measure) if (body.confirmEmail !== user.email) { - throw new Error('Email confirmation does not match'); + throw new BadRequestException('Email confirmation does not match'); + } + + if (user.role === 'MANAGER') { + const { canSelfDelete } = await this.gdprService.canManagerSelfDelete( + user.id, + user.organizationId + ); + if (!canSelfDelete) { + throw new ForbiddenException( + 'You are the only manager in your organization. Delete the entire organization or assign another manager first.' + ); + } } await this.gdprService.deleteUserData(user.id, body.reason); } + /** + * Delete entire organization and all its data (manager only) + */ + @Delete('delete-organization') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete organization and all its data', + description: + 'Permanently deletes the organization, all its users, and all associated bookings. Manager only.', + }) + @ApiResponse({ status: 204, description: 'Organization deleted' }) + async deleteOrganization( + @CurrentUser() user: UserPayload, + @Body() body: { reason?: string; confirmEmail: string } + ): Promise { + if (user.role !== 'MANAGER') { + throw new ForbiddenException('Only managers can delete an organization'); + } + + if (body.confirmEmail !== user.email) { + throw new BadRequestException('Email confirmation does not match'); + } + + await this.gdprService.deleteOrganizationData(user.organizationId, body.reason); + } + /** * Record consent */ diff --git a/apps/backend/src/application/gdpr/gdpr.module.ts b/apps/backend/src/application/gdpr/gdpr.module.ts index 6869942..4a01a7f 100644 --- a/apps/backend/src/application/gdpr/gdpr.module.ts +++ b/apps/backend/src/application/gdpr/gdpr.module.ts @@ -9,20 +9,24 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { GDPRController } from '../controllers/gdpr.controller'; import { GDPRService } from '../services/gdpr.service'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity'; import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity'; import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity'; import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; @Module({ imports: [ TypeOrmModule.forFeature([ UserOrmEntity, + OrganizationOrmEntity, BookingOrmEntity, AuditLogOrmEntity, NotificationOrmEntity, CookieConsentOrmEntity, ]), + SubscriptionsModule, ], controllers: [GDPRController], providers: [GDPRService], diff --git a/apps/backend/src/application/services/gdpr.service.ts b/apps/backend/src/application/services/gdpr.service.ts index d7784d2..cf3b699 100644 --- a/apps/backend/src/application/services/gdpr.service.ts +++ b/apps/backend/src/application/services/gdpr.service.ts @@ -10,8 +10,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto'; +import { SubscriptionService } from './subscription.service'; export interface GDPRDataExport { exportDate: string; @@ -28,8 +30,11 @@ export class GDPRService { constructor( @InjectRepository(UserOrmEntity) private readonly userRepository: Repository, + @InjectRepository(OrganizationOrmEntity) + private readonly organizationRepository: Repository, @InjectRepository(CookieConsentOrmEntity) - private readonly consentRepository: Repository + private readonly consentRepository: Repository, + private readonly subscriptionService: SubscriptionService ) {} /** @@ -82,41 +87,70 @@ export class GDPRService { return exportData; } + /** + * Check if a manager can delete their own account (i.e. another manager exists in the org) + */ + async canManagerSelfDelete( + userId: string, + organizationId: string + ): Promise<{ canSelfDelete: boolean; otherManagersCount: number }> { + const orgUsers = await this.userRepository.find({ where: { organizationId } }); + const otherManagers = orgUsers.filter(u => u.role === 'MANAGER' && u.id !== userId); + return { canSelfDelete: otherManagers.length > 0, otherManagersCount: otherManagers.length }; + } + /** * Delete user data (GDPR Article 17 - Right to Erasure) - * Note: This is a simplified version. In production, implement full anonymization logic. */ async deleteUserData(userId: string, reason?: string): Promise { this.logger.warn( `Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}` ); - // Verify user exists const user = await this.userRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException('User not found'); } try { - // Delete consent data first (will cascade with user deletion) await this.consentRepository.delete({ userId }); - - // IMPORTANT: In production, implement full data anonymization - // For now, we just mark the account for deletion - // Real implementation should: - // 1. Anonymize bookings (keep for legal retention) - // 2. Delete notifications - // 3. Anonymize audit logs - // 4. Anonymize user record - - this.logger.warn(`User ${userId} marked for deletion. Full implementation pending.`); - this.logger.log(`Data deletion initiated for user ${userId}`); + await this.subscriptionService.revokeLicense(userId); + await this.userRepository.delete({ id: userId }); + this.logger.warn(`User ${userId} (${user.email}) account deleted`); } catch (error: any) { this.logger.error(`Data deletion failed for user ${userId}: ${error.message}`, error.stack); throw error; } } + /** + * Delete entire organization and all its data (MANAGER action) + * Cascade deletes all users and bookings via DB constraints + */ + async deleteOrganizationData(organizationId: string, reason?: string): Promise { + this.logger.warn( + `Initiating organization deletion for org ${organizationId}. Reason: ${reason || 'Manager request'}` + ); + + const org = await this.organizationRepository.findOne({ where: { id: organizationId } }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + + const orgUsers = await this.userRepository.find({ where: { organizationId } }); + + for (const orgUser of orgUsers) { + try { + await this.subscriptionService.revokeLicense(orgUser.id); + } catch (error: any) { + this.logger.warn(`Failed to revoke license for user ${orgUser.id}: ${error.message}`); + } + } + + await this.organizationRepository.delete({ id: organizationId }); + this.logger.warn(`Organization ${organizationId} (${org.name}) deleted with all data`); + } + /** * Record or update consent (GDPR Article 7 - Conditions for consent) */ diff --git a/apps/frontend/app/[locale]/dashboard/profile/page.tsx b/apps/frontend/app/[locale]/dashboard/profile/page.tsx index 7c315f3..deb7d93 100644 --- a/apps/frontend/app/[locale]/dashboard/profile/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/profile/page.tsx @@ -8,12 +8,13 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslations } from 'next-intl'; import { updateUser, changePassword } from '@/lib/api'; +import { DeleteAccountTab } from '@/components/profile/DeleteAccountTab'; export default function ProfilePage() { const t = useTranslations('dashboard.profile'); const { user, refreshUser, loading } = useAuth(); const queryClient = useQueryClient(); - const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile'); + const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'danger'>('profile'); const [successMessage, setSuccessMessage] = useState(''); const [errorMessage, setErrorMessage] = useState(''); @@ -243,11 +244,23 @@ export default function ProfilePage() { > {t('tabs.password')} +
- {activeTab === 'profile' ? ( + {activeTab === 'danger' ? ( + + ) : activeTab === 'profile' ? (
diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index a847fca..885e17e 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -673,7 +673,8 @@ "active": "Active", "tabs": { "profile": "Personal information", - "password": "Change password" + "password": "Change password", + "danger": "Delete account" }, "profileForm": { "firstName": "First name", @@ -685,6 +686,32 @@ "successUpdate": "Profile updated successfully!", "errorUpdate": "Failed to update profile" }, + "deleteAccount": { + "cancel": "Cancel", + "deleting": "Deleting...", + "selfDelete": { + "title": "Delete my account", + "description": "Permanently deletes your account. Your organization and its data will remain intact.", + "button": "Delete my account", + "onlyManagerWarning": "You are the only manager in your organization. You must assign another manager before you can delete your account.", + "confirmInstruction": "Enter your email address to confirm deletion:", + "confirmButton": "Confirm deletion" + }, + "orgDelete": { + "title": "Delete entire organization", + "description": "This action is irreversible. It will permanently delete:", + "item1": "Your account and all user accounts", + "item2": "The organization and all its data", + "item3": "All bookings and associated documents", + "button": "Delete organization and all its content", + "confirmInstruction": "Enter your email address to confirm full deletion:", + "confirmButton": "Confirm full deletion" + }, + "errors": { + "selfDeleteFailed": "Failed to delete account", + "orgDeleteFailed": "Failed to delete organization" + } + }, "passwordForm": { "current": "Current password", "new": "New password", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index de6060e..b6fe859 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -673,7 +673,8 @@ "active": "Actif", "tabs": { "profile": "Informations personnelles", - "password": "Modifier le mot de passe" + "password": "Modifier le mot de passe", + "danger": "Supprimer le compte" }, "profileForm": { "firstName": "Prénom", @@ -685,6 +686,32 @@ "successUpdate": "Profil mis à jour avec succès !", "errorUpdate": "Échec de la mise à jour du profil" }, + "deleteAccount": { + "cancel": "Annuler", + "deleting": "Suppression...", + "selfDelete": { + "title": "Supprimer mon compte", + "description": "Supprime définitivement votre compte. La société et ses données resteront intactes.", + "button": "Supprimer mon compte", + "onlyManagerWarning": "Vous êtes le seul manager de votre organisation. Vous devez d'abord désigner un autre manager avant de pouvoir supprimer votre compte.", + "confirmInstruction": "Saisissez votre adresse email pour confirmer la suppression :", + "confirmButton": "Confirmer la suppression" + }, + "orgDelete": { + "title": "Supprimer toute l'organisation", + "description": "Cette action est irréversible. Elle supprimera définitivement :", + "item1": "Votre compte et tous les comptes utilisateurs", + "item2": "L'organisation et toutes ses données", + "item3": "Toutes les réservations et documents associés", + "button": "Supprimer l'organisation et tout son contenu", + "confirmInstruction": "Saisissez votre adresse email pour confirmer la suppression totale :", + "confirmButton": "Confirmer la suppression totale" + }, + "errors": { + "selfDeleteFailed": "Échec de la suppression du compte", + "orgDeleteFailed": "Échec de la suppression de l'organisation" + } + }, "passwordForm": { "current": "Mot de passe actuel", "new": "Nouveau mot de passe", diff --git a/apps/frontend/src/components/profile/DeleteAccountTab.tsx b/apps/frontend/src/components/profile/DeleteAccountTab.tsx new file mode 100644 index 0000000..9f21cd0 --- /dev/null +++ b/apps/frontend/src/components/profile/DeleteAccountTab.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useAuth } from '@/lib/context/auth-context'; +import { checkCanSelfDelete, requestAccountDeletion, deleteMyOrganization } from '@/lib/api'; + +export function DeleteAccountTab() { + const t = useTranslations('dashboard.profile.deleteAccount'); + const router = useRouter(); + const { user, logout } = useAuth(); + + const [selfDeleteEmail, setSelfDeleteEmail] = useState(''); + const [orgDeleteEmail, setOrgDeleteEmail] = useState(''); + const [selfDeleteError, setSelfDeleteError] = useState(''); + const [orgDeleteError, setOrgDeleteError] = useState(''); + const [showSelfConfirm, setShowSelfConfirm] = useState(false); + const [showOrgConfirm, setShowOrgConfirm] = useState(false); + + const isManager = user?.role === 'MANAGER'; + + const { data: selfDeleteCheck, isLoading: checkLoading } = useQuery({ + queryKey: ['can-self-delete'], + queryFn: checkCanSelfDelete, + enabled: isManager, + }); + + const selfDeleteMutation = useMutation({ + mutationFn: () => requestAccountDeletion(selfDeleteEmail), + onSuccess: async () => { + await logout(); + router.push('/login'); + }, + onError: (error: any) => { + setSelfDeleteError(error.message || t('errors.selfDeleteFailed')); + }, + }); + + const orgDeleteMutation = useMutation({ + mutationFn: () => deleteMyOrganization(orgDeleteEmail), + onSuccess: async () => { + await logout(); + router.push('/login'); + }, + onError: (error: any) => { + setOrgDeleteError(error.message || t('errors.orgDeleteFailed')); + }, + }); + + const canSelfDelete = !isManager || selfDeleteCheck?.canSelfDelete === true; + + return ( +
+ {/* Self-delete section */} +
+

{t('selfDelete.title')}

+

{t('selfDelete.description')}

+ + {isManager && !checkLoading && !canSelfDelete && ( +
+ + + +

{t('selfDelete.onlyManagerWarning')}

+
+ )} + + {!showSelfConfirm ? ( + + ) : ( +
+

{t('selfDelete.confirmInstruction')}

+ { + setSelfDeleteEmail(e.target.value); + setSelfDeleteError(''); + }} + placeholder={user?.email || ''} + className="w-full px-4 py-2 border border-red-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent text-sm" + /> + {selfDeleteError && ( +

{selfDeleteError}

+ )} +
+ + +
+
+ )} +
+ + {/* Full org delete section — manager only */} + {isManager && ( +
+
+ + + +

{t('orgDelete.title')}

+
+

{t('orgDelete.description')}

+
    +
  • {t('orgDelete.item1')}
  • +
  • {t('orgDelete.item2')}
  • +
  • {t('orgDelete.item3')}
  • +
+ + {!showOrgConfirm ? ( + + ) : ( +
+

{t('orgDelete.confirmInstruction')}

+ { + setOrgDeleteEmail(e.target.value); + setOrgDeleteError(''); + }} + placeholder={user?.email || ''} + className="w-full px-4 py-2 border border-red-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent text-sm" + /> + {orgDeleteError && ( +

{orgDeleteError}

+ )} +
+ + +
+
+ )} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/lib/api/gdpr.ts b/apps/frontend/src/lib/api/gdpr.ts index c6f9a95..e0bb2d2 100644 --- a/apps/frontend/src/lib/api/gdpr.ts +++ b/apps/frontend/src/lib/api/gdpr.ts @@ -4,7 +4,7 @@ * Endpoints for GDPR compliance (data export, deletion, consent) */ -import { get, post, patch } from './client'; +import { get, post, getAuthToken } from './client'; import type { SuccessResponse } from '@/types/api'; /** @@ -55,7 +55,7 @@ export async function requestDataExport(): Promise { method: 'GET', headers: { Authorization: `Bearer ${ - typeof window !== 'undefined' ? localStorage.getItem('accessToken') : '' + getAuthToken() ?? '' }`, }, }); @@ -76,7 +76,7 @@ export async function requestDataExportCSV(): Promise { method: 'GET', headers: { Authorization: `Bearer ${ - typeof window !== 'undefined' ? localStorage.getItem('accessToken') : '' + getAuthToken() ?? '' }`, }, }); @@ -99,14 +99,53 @@ export async function requestAccountDeletion(confirmEmail: string, reason?: stri headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ - typeof window !== 'undefined' ? localStorage.getItem('accessToken') : '' + getAuthToken() ?? '' }`, }, body: JSON.stringify({ confirmEmail, reason }), }); if (!response.ok) { - throw new Error(`Deletion failed: ${response.statusText}`); + const data = await response.json().catch(() => ({})); + throw new Error(data?.message || `Deletion failed: ${response.statusText}`); + } +} + +/** + * Check if current user can self-delete their account + * GET /api/v1/gdpr/can-self-delete + */ +export async function checkCanSelfDelete(): Promise<{ + canSelfDelete: boolean; + otherManagersCount: number; +}> { + return get<{ canSelfDelete: boolean; otherManagersCount: number }>( + '/api/v1/gdpr/can-self-delete' + ); +} + +/** + * Delete entire organization and all its data (manager only) + * DELETE /api/v1/gdpr/delete-organization + */ +export async function deleteMyOrganization(confirmEmail: string, reason?: string): Promise { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-organization`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ + getAuthToken() ?? '' + }`, + }, + body: JSON.stringify({ confirmEmail, reason }), + } + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data?.message || `Deletion failed: ${response.statusText}`); } } diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index 6a56e2e..f64da4b 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -113,11 +113,13 @@ export { listWebhookEvents, } from './webhooks'; -// GDPR (6 endpoints) +// GDPR (8 endpoints) export { requestDataExport, requestDataExportCSV, requestAccountDeletion, + checkCanSelfDelete, + deleteMyOrganization, getConsentPreferences, updateConsentPreferences, withdrawConsent,