fix feature

This commit is contained in:
David 2026-05-13 17:17:42 +02:00
parent 3d65693395
commit e92de273fc
9 changed files with 430 additions and 28 deletions

View File

@ -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
*/ */

View File

@ -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],

View File

@ -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)
*/ */

View File

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

View File

@ -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",

View File

@ -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",

View 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>
);
}

View File

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

View File

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