fix feature
This commit is contained in:
parent
3d65693395
commit
e92de273fc
@ -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<void> {
|
||||
// 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<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
|
||||
*/
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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<UserOrmEntity>,
|
||||
@InjectRepository(OrganizationOrmEntity)
|
||||
private readonly organizationRepository: Repository<OrganizationOrmEntity>,
|
||||
@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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<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)
|
||||
*/
|
||||
|
||||
@ -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')}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'profile' ? (
|
||||
{activeTab === 'danger' ? (
|
||||
<DeleteAccountTab />
|
||||
) : activeTab === 'profile' ? (
|
||||
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
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)
|
||||
*/
|
||||
|
||||
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<Blob> {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
getAuthToken() ?? ''
|
||||
}`,
|
||||
},
|
||||
});
|
||||
@ -76,7 +76,7 @@ export async function requestDataExportCSV(): Promise<Blob> {
|
||||
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<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,
|
||||
} from './webhooks';
|
||||
|
||||
// GDPR (6 endpoints)
|
||||
// GDPR (8 endpoints)
|
||||
export {
|
||||
requestDataExport,
|
||||
requestDataExportCSV,
|
||||
requestAccountDeletion,
|
||||
checkCanSelfDelete,
|
||||
deleteMyOrganization,
|
||||
getConsentPreferences,
|
||||
updateConsentPreferences,
|
||||
withdrawConsent,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user