Skip to content
33 changes: 33 additions & 0 deletions .claude/rgpd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Données personnelles et durées de conservation (#431)

Ce qu'RehearsalBox garde d'une personne, pourquoi, combien de temps, et ce que la personne peut faire elle-même. À tenir à jour à chaque nouvelle donnée personnelle (nouvelle colonne, nouveau journal, nouvel e-mail).

## Inventaire et durées

| Donnée | Où | Pourquoi | Durée de conservation |
|---|---|---|---|
| Nom affiché, adresse e-mail, rôle, état du compte | `users` | identifier la personne, la joindre, contrôler l'accès | tant que le compte existe ; **anonymisés à la suppression** (la ligne reste, « Ancien membre », adresse inutilisable) |
| Empreinte du mot de passe, compteur d'échecs, version de session | `users` | connexion sécurisée | idem ; l'empreinte est remplacée par un secret aléatoire à la suppression |
| Appartenance aux groupes et rôle | `group_user` | droits d'accès | supprimée avec le compte |
| Messages (texte), titres de conversation, anciennes versions corrigées | `conversation_*` | la messagerie entre groupes | texte **chiffré en base** (#171). Conservés tant que la conversation existe (ils appartiennent aussi aux autres participants) ; corbeille : 30 jours avant effacement définitif ; anciennes versions corrigées : 30 jours |
| Réservations libres, demandes d'échange de créneau | `free_slot_bookings`, `slot_exceptions` | gérer l'occupation du local | tant que le groupe existe ; rattachées à la ligne anonymisée après suppression du compte |
| Jetons de réinitialisation / changement d'adresse | `password_resets`, `email_changes` | opérations sécurisées | usage unique, 1 heure ; révoqués à la suppression du compte |
| Évènements de limitation (empreintes d'adresse IP et d'identifiant) | `throttle_events` | freiner les essais de connexion | 24 heures, empreintes seulement, jamais d'adresse en clair |
| Mesures d'exploitation (adresse IP **hachée** avec un secret, route, statut) | `metric_events`, `metric_hourly` | santé du site, détection d'abus | événements 30 jours (plafonnés par adresse et par jour, #442) ; agrégats 90 jours ; aucune adresse en clair |
| Journal d'erreurs applicatif | `storage/logs` (hors base) | diagnostic | rotation par taille (voir `logging` dans `config/config.php`), fichier réservé au propriétaire ; jamais de mot de passe ni de jeton |
| Documents des groupes (PDF, images) | `storage/group-documents` | partage de fiches techniques | tant que le groupe les garde ; images ré-encodées sans métadonnées (#432) |
| Sauvegardes de la base | `backups/` (hors racine web) | reprise après incident | 14 quotidiennes + 7 d'avant déploiement, puis effacées ; **une suppression de compte n'y est appliquée qu'à leur expiration** |

## Droits exercés par la personne, depuis « Mon compte »

- **Accès / portabilité** : « Télécharger mes données (JSON) » (`GET /api/account/export`) : profil, groupes, messages écrits (texte déchiffré), réservations et demandes. Aucun secret, aucune donnée d'autrui.
- **Effacement** : « Supprimer mon compte » (`DELETE /api/account`, mot de passe + mot SUPPRIMER). Voir `AccountDeletionService` : anonymisation, sortie des groupes, révocation des liens et des sessions, e-mail de confirmation à l'ancienne adresse. Refusé tant qu'un compte est administrateur ou dernier gestionnaire d'un groupe (il passe la main d'abord).
- **Rectification** : nom et adresse modifiables dans « Mon compte ».

## Limites connues et décisions

- Les messages envoyés **restent** dans les conversations des autres sous « Ancien membre » : ils font partie de leurs échanges. Une demande d'effacement des messages eux-mêmes se traite à la main (administrateur) : pas de fonction libre-service.
- Les mentions et libellés (`message_mentions.label`) gardent le nom tel qu'il était au moment du message.
- L'annuaire interne (recherche de membres, messages directs) est visible de tout membre connecté par construction ; aucune adresse e-mail n'y figure (#429).
- Les sauvegardes ne sont pas réécrites : un compte supprimé peut y subsister jusqu'à 14 jours (quotidiennes). À mentionner à la personne qui le demande.
- Aucun traceur ni service tiers côté navigateur (CSP `default-src 'self'`) ; le lieu d'hébergement et le contrat de sous-traitance relèvent du propriétaire (hors dépôt public).
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Sommaire de référence. Lire avant toute intervention — suivre les liens pour
| Layout CSS, mobile-first | [.claude/layout.md](.claude/layout.md) |
| CI/CD, suivi | [.claude/cicd.md](.claude/cicd.md) |
| Déploiement (WIP, hébergeur non choisi) | [.claude/deploiement.md](.claude/deploiement.md) |
| Données personnelles, durées, droits | [.claude/rgpd.md](.claude/rgpd.md) |

---

Expand Down
3 changes: 3 additions & 0 deletions config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use App\Account\Controller\Api\AccountApiController;
use App\Account\Controller\Api\AccountDataApiController;
use App\Account\Controller\Api\AuthApiController;
use App\Planning\Controller\Api\AvailabilityApiController;
use App\Backup\Controller\RestoreController;
Expand Down Expand Up @@ -68,6 +69,8 @@
['PATCH', '/api/account/email', [AccountApiController::class, 'requestEmailChange']],
['POST', '/api/account/email/confirm', [AccountApiController::class, 'confirmEmailChange']],
['POST', '/api/auth/change-password', [AccountApiController::class, 'changePassword']],
['GET', '/api/account/export', [AccountDataApiController::class, 'export']],
['DELETE', '/api/account', [AccountDataApiController::class, 'delete']],
['POST', '/api/auth/secure-account', [AccountApiController::class, 'secureAccount']],
['POST', '/api/auth/logout', [AuthApiController::class, 'logout']],
['POST', '/api/auth/select-group', [AuthApiController::class, 'selectGroup']],
Expand Down
31 changes: 31 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
use App\Metrics\Collection\MysqlMetricsRepository;
use Psr\Log\LoggerInterface;
use App\Account\Controller\Api\AccountApiController;
use App\Account\Controller\Api\AccountDataApiController;
use App\Account\Repository\AccountDataRepositoryInterface;
use App\Account\Repository\MysqlAccountDataRepository;
use App\Account\Service\Data\AccountDataExporter;
use App\Account\Service\Data\AccountDeletionService;
use App\Account\Controller\Api\AuthApiController;
use App\Planning\Controller\Api\AvailabilityApiController;
use App\Planning\Controller\AdminBookingPageController;
Expand Down Expand Up @@ -481,6 +486,32 @@
$c->get(EmailChangeService::class),
));

// Mes données (#431) : export et suppression de MON compte (le compte est celui de la session).
$container->set(AccountDataRepositoryInterface::class, fn ($c) => new MysqlAccountDataRepository($c->get(PDO::class), $c->get(MessageCipher::class)));
$container->set(AccountDataExporter::class, fn ($c) => new AccountDataExporter(
$c->get(UserRepositoryInterface::class),
$c->get(AccountDataRepositoryInterface::class),
$c->get(ClockInterface::class),
));
$container->set(AccountDeletionService::class, fn ($c) => new AccountDeletionService(
$c->get(UserRepositoryInterface::class),
$c->get(GroupRepositoryInterface::class),
new GroupManagerService($c->get(GroupRepositoryInterface::class), $c->get(GroupManagerRepositoryInterface::class)),
new CurrentPasswordVerifier($c->get(UserRepositoryInterface::class), $c->get(PasswordHasherInterface::class)),
$c->get(PasswordResetRepositoryInterface::class),
$c->get(EmailChangeRepositoryInterface::class),
$c->get(PasswordHasherInterface::class),
$c->get(TransactionRunner::class),
$c->get(Mailbox::class),
$c->get(ClockInterface::class),
));
$container->set(AccountDataApiController::class, fn ($c) => new AccountDataApiController(
$c->get(AuthGuard::class),
$c->get(AuthServiceInterface::class),
$c->get(AccountDataExporter::class),
$c->get(AccountDeletionService::class),
));

$container->set(EmailChangeRepositoryInterface::class, fn ($c) => new MysqlEmailChangeRepository($c->get(PDO::class)));

$container->set(EmailChangeService::class, fn ($c) => new EmailChangeService(
Expand Down
8 changes: 8 additions & 0 deletions public/assets/js/account/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ export function initAuth() {
form.addEventListener('async-error', (event) => showToast(event.detail.message, 'error'));
});

document.querySelectorAll('rb-async-form[endpoint="/api/account"]').forEach((form) => {
form.addEventListener('async-success', () => {
clearAllDrafts(); // plus aucun brouillon de message lisible sur cet appareil
window.location.href = '/login';
});
form.addEventListener('async-error', (event) => showToast(event.detail.message, 'error'));
});

document.querySelectorAll('rb-async-form[endpoint*="/auth/secure-account"]').forEach((form) => {
form.addEventListener('async-success', () => revealConfirmation(form));
form.addEventListener('async-error', (event) => showToast(event.detail.message, 'error'));
Expand Down
61 changes: 61 additions & 0 deletions src/Account/Controller/Api/AccountDataApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace App\Account\Controller\Api;

use App\Account\Exception\UserValidationException;
use App\Account\Service\Data\AccountDataExporter;
use App\Account\Service\Data\AccountDeletionService;
use App\Account\Service\AuthServiceInterface;
use App\Http\JsonResponse;
use App\Http\Request;
use App\Security\AuthGuard;

/**
* Mes données (#431) : export et suppression de MON compte. Le compte est celui de la session : aucun identifiant n'est lu dans la
* requĂŞte (un autre compte n'est jamais adressable par cette route).
*/
final class AccountDataApiController
{
public const CONFIRM_WORD = 'SUPPRIMER';

public function __construct(
private readonly AuthGuard $authGuard,
private readonly AuthServiceInterface $authService,
private readonly AccountDataExporter $exporter,
private readonly AccountDeletionService $deletion,
) {
}

/** Téléchargement JSON de mes données (jamais mis en cache : politique par défaut du Kernel). */
public function export(Request $request): JsonResponse
{
$user = $this->authGuard->requireLogin();

return new JsonResponse(
$this->exporter->export($user->id()),
200,
['Content-Disposition' => 'attachment; filename="rehearsalbox-mes-donnees.json"'],
);
}

/** Suppression de mon compte : mot de passe actuel ET mot de confirmation ; la session est fermée ensuite. */
public function delete(Request $request): JsonResponse
{
$user = $this->authGuard->requireLogin();

$password = $request->body('password');
if ($request->body('confirmation') !== self::CONFIRM_WORD) {
throw new UserValidationException(['confirmation' => 'Saisissez « ' . self::CONFIRM_WORD . ' » pour confirmer.']);
}
if (!is_string($password) || $password === '') {
throw new UserValidationException(['currentPassword' => 'Votre mot de passe est requis.']);
}

$this->deletion->delete($user->id(), $password);
$this->authService->logout();

return new JsonResponse([], 204);
}
}
33 changes: 33 additions & 0 deletions src/Account/Repository/AccountDataRepositoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Account\Repository;

/** Lecture seule de ce que l'application garde d'UNE personne (#431) : export de ses données, jamais celles d'une autre. */
interface AccountDataRepositoryInterface
{
/** @return list<array{groupId: int, group: string, role: string}> */
public function memberships(int $userId): array;

/**
* Messages écrits par la personne (texte déchiffré), hors lignes système.
*
* @return list<array{id: int, conversationId: int, sentAt: string, editedAt: ?string, text: string}>
*/
public function messages(int $userId): array;

/**
* Réservations libres demandées par la personne.
*
* @return list<array{group: string, date: string, start: string, end: string, status: string, reason: ?string, createdAt: string}>
*/
public function bookings(int $userId): array;

/**
* Demandes d'échange de créneau faites par la personne.
*
* @return list<array{group: string, date: string, status: string, reason: ?string, createdAt: string}>
*/
public function slotRequests(int $userId): array;
}
79 changes: 79 additions & 0 deletions src/Account/Repository/MysqlAccountDataRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace App\Account\Repository;

use App\Messaging\Crypto\MessageCipher;

final class MysqlAccountDataRepository implements AccountDataRepositoryInterface
{
public function __construct(private readonly \PDO $pdo, private readonly MessageCipher $cipher)
{
}

public function memberships(int $userId): array
{
$statement = $this->pdo->prepare(
'SELECT g.id, g.name, gu.role FROM group_user gu JOIN `groups` g ON g.id = gu.group_id WHERE gu.user_id = :user_id ORDER BY g.name, g.id'
);
$statement->execute(['user_id' => $userId]);

return array_map(
static fn (array $row): array => ['groupId' => (int) $row['id'], 'group' => (string) $row['name'], 'role' => (string) $row['role']],
$statement->fetchAll(\PDO::FETCH_ASSOC),
);
}

public function messages(int $userId): array
{
$statement = $this->pdo->prepare(
'SELECT id, conversation_id, body, created_at, edited_at FROM conversation_messages WHERE author_id = :user_id AND is_system = 0 ORDER BY id'
);
$statement->execute(['user_id' => $userId]);

return array_map(fn (array $row): array => [
'id' => (int) $row['id'],
'conversationId' => (int) $row['conversation_id'],
'sentAt' => (string) $row['created_at'],
'editedAt' => $row['edited_at'] === null ? null : (string) $row['edited_at'],
'text' => $this->cipher->decrypt((string) $row['body']),
], $statement->fetchAll(\PDO::FETCH_ASSOC));
}

public function bookings(int $userId): array
{
$statement = $this->pdo->prepare(
'SELECT g.name, b.booking_date, b.start_time, b.end_time, b.status, b.reason, b.created_at
FROM free_slot_bookings b JOIN `groups` g ON g.id = b.group_id WHERE b.booked_by_user_id = :user_id ORDER BY b.booking_date, b.start_time, b.id'
);
$statement->execute(['user_id' => $userId]);

return array_map(static fn (array $row): array => [
'group' => (string) $row['name'],
'date' => (string) $row['booking_date'],
'start' => substr((string) $row['start_time'], 0, 5),
'end' => substr((string) $row['end_time'], 0, 5),
'status' => (string) $row['status'],
'reason' => $row['reason'] === null ? null : (string) $row['reason'],
'createdAt' => (string) $row['created_at'],
], $statement->fetchAll(\PDO::FETCH_ASSOC));
}

public function slotRequests(int $userId): array
{
$statement = $this->pdo->prepare(
'SELECT g.name, e.occurrence_date, e.status, e.request_reason, e.created_at
FROM slot_exceptions e JOIN `groups` g ON g.id = e.requested_by_group_id WHERE e.requested_by_user_id = :user_id ORDER BY e.occurrence_date, e.id'
);
$statement->execute(['user_id' => $userId]);

return array_map(static fn (array $row): array => [
'group' => (string) $row['name'],
'date' => (string) $row['occurrence_date'],
'status' => (string) $row['status'],
'reason' => $row['request_reason'] === null ? null : (string) $row['request_reason'],
'createdAt' => (string) $row['created_at'],
], $statement->fetchAll(\PDO::FETCH_ASSOC));
}
}
50 changes: 50 additions & 0 deletions src/Account/Service/Data/AccountDataExporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace App\Account\Service\Data;

use App\Account\Exception\UserNotFoundException;
use App\Account\Repository\AccountDataRepositoryInterface;
use App\Account\Repository\UserRepositoryInterface;
use Symfony\Component\Clock\ClockInterface;

/**
* Export des données d'UNE personne (#431), à sa demande : son profil, ses groupes, les messages qu'elle a écrits, ses réservations et
* ses demandes de créneau. Jamais de secret (mot de passe, empreinte, compteur de session) ni de donnée d'autrui. L'identité vient de la
* session de l'appelant, jamais de la requĂŞte.
*/
final class AccountDataExporter
{
public function __construct(
private readonly UserRepositoryInterface $users,
private readonly AccountDataRepositoryInterface $data,
private readonly ClockInterface $clock,
) {
}

/**
* @return array{generatedAt: string, account: array{id: int, email: string, displayName: string, role: string, active: bool}, groups: list<array<string, mixed>>, messages: list<array<string, mixed>>, bookings: list<array<string, mixed>>, slotRequests: list<array<string, mixed>>}
*
* @throws UserNotFoundException
*/
public function export(int $userId): array
{
$user = $this->users->findById($userId) ?? throw new UserNotFoundException("Utilisateur {$userId} introuvable.");

return [
'generatedAt' => $this->clock->now()->format(\DATE_ATOM),
'account' => [
'id' => $user->id(),
'email' => $user->email(),
'displayName' => $user->displayName(),
'role' => $user->role()->value,
'active' => $user->isActive(),
],
'groups' => $this->data->memberships($userId),
'messages' => $this->data->messages($userId),
'bookings' => $this->data->bookings($userId),
'slotRequests' => $this->data->slotRequests($userId),
];
}
}
Loading
Loading