fix feature
This commit is contained in:
parent
3d65693395
commit
e92de273fc
@ -15,6 +15,8 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Res,
|
Res,
|
||||||
Req,
|
Req,
|
||||||
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||||
import { Response, Request } from 'express';
|
import { Response, Request } from 'express';
|
||||||
@ -96,6 +98,24 @@ export class GDPRController {
|
|||||||
res.send(csv);
|
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)
|
* Delete user data (GDPR Right to Erasure)
|
||||||
*/
|
*/
|
||||||
@ -107,20 +127,57 @@ export class GDPRController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 204,
|
status: 204,
|
||||||
description: 'Account deletion initiated',
|
description: 'Account deleted',
|
||||||
})
|
})
|
||||||
async deleteAccount(
|
async deleteAccount(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: { reason?: string; confirmEmail: string }
|
@Body() body: { reason?: string; confirmEmail: string }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Verify email confirmation (security measure)
|
|
||||||
if (body.confirmEmail !== user.email) {
|
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);
|
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<void> {
|
||||||
|
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
|
* Record consent
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -9,20 +9,24 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { GDPRController } from '../controllers/gdpr.controller';
|
import { GDPRController } from '../controllers/gdpr.controller';
|
||||||
import { GDPRService } from '../services/gdpr.service';
|
import { GDPRService } from '../services/gdpr.service';
|
||||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
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 { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
||||||
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
|
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
|
||||||
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
||||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||||
|
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
UserOrmEntity,
|
UserOrmEntity,
|
||||||
|
OrganizationOrmEntity,
|
||||||
BookingOrmEntity,
|
BookingOrmEntity,
|
||||||
AuditLogOrmEntity,
|
AuditLogOrmEntity,
|
||||||
NotificationOrmEntity,
|
NotificationOrmEntity,
|
||||||
CookieConsentOrmEntity,
|
CookieConsentOrmEntity,
|
||||||
]),
|
]),
|
||||||
|
SubscriptionsModule,
|
||||||
],
|
],
|
||||||
controllers: [GDPRController],
|
controllers: [GDPRController],
|
||||||
providers: [GDPRService],
|
providers: [GDPRService],
|
||||||
|
|||||||
@ -10,8 +10,10 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
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 { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||||
import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto';
|
import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto';
|
||||||
|
import { SubscriptionService } from './subscription.service';
|
||||||
|
|
||||||
export interface GDPRDataExport {
|
export interface GDPRDataExport {
|
||||||
exportDate: string;
|
exportDate: string;
|
||||||
@ -28,8 +30,11 @@ export class GDPRService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserOrmEntity)
|
@InjectRepository(UserOrmEntity)
|
||||||
private readonly userRepository: Repository<UserOrmEntity>,
|
private readonly userRepository: Repository<UserOrmEntity>,
|
||||||
|
@InjectRepository(OrganizationOrmEntity)
|
||||||
|
private readonly organizationRepository: Repository<OrganizationOrmEntity>,
|
||||||
@InjectRepository(CookieConsentOrmEntity)
|
@InjectRepository(CookieConsentOrmEntity)
|
||||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>
|
private readonly consentRepository: Repository<CookieConsentOrmEntity>,
|
||||||
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -82,41 +87,70 @@ export class GDPRService {
|
|||||||
return exportData;
|
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)
|
* 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<void> {
|
async deleteUserData(userId: string, reason?: string): Promise<void> {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
|
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify user exists
|
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete consent data first (will cascade with user deletion)
|
|
||||||
await this.consentRepository.delete({ userId });
|
await this.consentRepository.delete({ userId });
|
||||||
|
await this.subscriptionService.revokeLicense(userId);
|
||||||
// IMPORTANT: In production, implement full data anonymization
|
await this.userRepository.delete({ id: userId });
|
||||||
// For now, we just mark the account for deletion
|
this.logger.warn(`User ${userId} (${user.email}) account deleted`);
|
||||||
// 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}`);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Data deletion failed for user ${userId}: ${error.message}`, error.stack);
|
this.logger.error(`Data deletion failed for user ${userId}: ${error.message}`, error.stack);
|
||||||
throw error;
|
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<void> {
|
||||||
|
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)
|
* Record or update consent (GDPR Article 7 - Conditions for consent)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -8,12 +8,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { updateUser, changePassword } from '@/lib/api';
|
import { updateUser, changePassword } from '@/lib/api';
|
||||||
|
import { DeleteAccountTab } from '@/components/profile/DeleteAccountTab';
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const t = useTranslations('dashboard.profile');
|
const t = useTranslations('dashboard.profile');
|
||||||
const { user, refreshUser, loading } = useAuth();
|
const { user, refreshUser, loading } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
|
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'danger'>('profile');
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
@ -243,11 +244,23 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
{t('tabs.password')}
|
{t('tabs.password')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('danger')}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'danger'
|
||||||
|
? 'border-red-500 text-red-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('tabs.danger')}
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{activeTab === 'profile' ? (
|
{activeTab === 'danger' ? (
|
||||||
|
<DeleteAccountTab />
|
||||||
|
) : activeTab === 'profile' ? (
|
||||||
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
|
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -673,7 +673,8 @@
|
|||||||
"active": "Active",
|
"active": "Active",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"profile": "Personal information",
|
"profile": "Personal information",
|
||||||
"password": "Change password"
|
"password": "Change password",
|
||||||
|
"danger": "Delete account"
|
||||||
},
|
},
|
||||||
"profileForm": {
|
"profileForm": {
|
||||||
"firstName": "First name",
|
"firstName": "First name",
|
||||||
@ -685,6 +686,32 @@
|
|||||||
"successUpdate": "Profile updated successfully!",
|
"successUpdate": "Profile updated successfully!",
|
||||||
"errorUpdate": "Failed to update profile"
|
"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": {
|
"passwordForm": {
|
||||||
"current": "Current password",
|
"current": "Current password",
|
||||||
"new": "New password",
|
"new": "New password",
|
||||||
|
|||||||
@ -673,7 +673,8 @@
|
|||||||
"active": "Actif",
|
"active": "Actif",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"profile": "Informations personnelles",
|
"profile": "Informations personnelles",
|
||||||
"password": "Modifier le mot de passe"
|
"password": "Modifier le mot de passe",
|
||||||
|
"danger": "Supprimer le compte"
|
||||||
},
|
},
|
||||||
"profileForm": {
|
"profileForm": {
|
||||||
"firstName": "Prénom",
|
"firstName": "Prénom",
|
||||||
@ -685,6 +686,32 @@
|
|||||||
"successUpdate": "Profil mis à jour avec succès !",
|
"successUpdate": "Profil mis à jour avec succès !",
|
||||||
"errorUpdate": "Échec de la mise à jour du profil"
|
"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": {
|
"passwordForm": {
|
||||||
"current": "Mot de passe actuel",
|
"current": "Mot de passe actuel",
|
||||||
"new": "Nouveau mot de passe",
|
"new": "Nouveau mot de passe",
|
||||||
|
|||||||
199
apps/frontend/src/components/profile/DeleteAccountTab.tsx
Normal file
199
apps/frontend/src/components/profile/DeleteAccountTab.tsx
Normal file
@ -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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Self-delete section */}
|
||||||
|
<div className="border border-red-200 rounded-lg p-6 bg-red-50">
|
||||||
|
<h3 className="text-lg font-semibold text-red-800 mb-2">{t('selfDelete.title')}</h3>
|
||||||
|
<p className="text-sm text-red-700 mb-4">{t('selfDelete.description')}</p>
|
||||||
|
|
||||||
|
{isManager && !checkLoading && !canSelfDelete && (
|
||||||
|
<div className="flex items-start gap-2 mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-amber-800">{t('selfDelete.onlyManagerWarning')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showSelfConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelfDeleteError('');
|
||||||
|
setShowSelfConfirm(true);
|
||||||
|
}}
|
||||||
|
disabled={!canSelfDelete || checkLoading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('selfDelete.button')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium text-red-800">{t('selfDelete.confirmInstruction')}</p>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={selfDeleteEmail}
|
||||||
|
onChange={e => {
|
||||||
|
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 && (
|
||||||
|
<p className="text-sm text-red-600">{selfDeleteError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => selfDeleteMutation.mutate()}
|
||||||
|
disabled={selfDeleteMutation.isPending || selfDeleteEmail !== user?.email}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{selfDeleteMutation.isPending ? t('deleting') : t('selfDelete.confirmButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowSelfConfirm(false);
|
||||||
|
setSelfDeleteEmail('');
|
||||||
|
setSelfDeleteError('');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full org delete section — manager only */}
|
||||||
|
{isManager && (
|
||||||
|
<div className="border-2 border-red-400 rounded-lg p-6 bg-red-50">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg className="w-5 h-5 text-red-700" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-lg font-semibold text-red-800">{t('orgDelete.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-700 mb-1">{t('orgDelete.description')}</p>
|
||||||
|
<ul className="list-disc list-inside text-sm text-red-700 mb-4 space-y-1 ml-2">
|
||||||
|
<li>{t('orgDelete.item1')}</li>
|
||||||
|
<li>{t('orgDelete.item2')}</li>
|
||||||
|
<li>{t('orgDelete.item3')}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{!showOrgConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOrgDeleteError('');
|
||||||
|
setShowOrgConfirm(true);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-700 rounded-lg hover:bg-red-800"
|
||||||
|
>
|
||||||
|
{t('orgDelete.button')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium text-red-800">{t('orgDelete.confirmInstruction')}</p>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={orgDeleteEmail}
|
||||||
|
onChange={e => {
|
||||||
|
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 && (
|
||||||
|
<p className="text-sm text-red-600">{orgDeleteError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => orgDeleteMutation.mutate()}
|
||||||
|
disabled={orgDeleteMutation.isPending || orgDeleteEmail !== user?.email}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-700 rounded-lg hover:bg-red-800 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{orgDeleteMutation.isPending ? t('deleting') : t('orgDelete.confirmButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowOrgConfirm(false);
|
||||||
|
setOrgDeleteEmail('');
|
||||||
|
setOrgDeleteError('');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@
|
|||||||
* Endpoints for GDPR compliance (data export, deletion, consent)
|
* 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';
|
import type { SuccessResponse } from '@/types/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,7 +55,7 @@ export async function requestDataExport(): Promise<Blob> {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${
|
Authorization: `Bearer ${
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
getAuthToken() ?? ''
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -76,7 +76,7 @@ export async function requestDataExportCSV(): Promise<Blob> {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${
|
Authorization: `Bearer ${
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
getAuthToken() ?? ''
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -99,14 +99,53 @@ export async function requestAccountDeletion(confirmEmail: string, reason?: stri
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${
|
Authorization: `Bearer ${
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
getAuthToken() ?? ''
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ confirmEmail, reason }),
|
body: JSON.stringify({ confirmEmail, reason }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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<void> {
|
||||||
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -113,11 +113,13 @@ export {
|
|||||||
listWebhookEvents,
|
listWebhookEvents,
|
||||||
} from './webhooks';
|
} from './webhooks';
|
||||||
|
|
||||||
// GDPR (6 endpoints)
|
// GDPR (8 endpoints)
|
||||||
export {
|
export {
|
||||||
requestDataExport,
|
requestDataExport,
|
||||||
requestDataExportCSV,
|
requestDataExportCSV,
|
||||||
requestAccountDeletion,
|
requestAccountDeletion,
|
||||||
|
checkCanSelfDelete,
|
||||||
|
deleteMyOrganization,
|
||||||
getConsentPreferences,
|
getConsentPreferences,
|
||||||
updateConsentPreferences,
|
updateConsentPreferences,
|
||||||
withdrawConsent,
|
withdrawConsent,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user