diff --git a/extensions/gdpr/extend.php b/extensions/gdpr/extend.php
index fe572b648f..8f0e9d13fb 100644
--- a/extensions/gdpr/extend.php
+++ b/extensions/gdpr/extend.php
@@ -70,8 +70,10 @@
(new Extend\Console())
->command(Console\DestroyExportsCommand::class)
->command(Console\ProcessEraseRequests::class)
+ ->command(Console\ClearConfirmationIps::class)
->schedule(Console\ProcessEraseRequests::class, Console\DailySchedule::class)
- ->schedule(Console\DestroyExportsCommand::class, Console\DailySchedule::class),
+ ->schedule(Console\DestroyExportsCommand::class, Console\DailySchedule::class)
+ ->schedule(Console\ClearConfirmationIps::class, Console\DailySchedule::class),
(new Extend\ServiceProvider())
->register(Providers\GdprProvider::class),
diff --git a/extensions/gdpr/js/src/forum/components/ErasureRequestsList.tsx b/extensions/gdpr/js/src/forum/components/ErasureRequestsList.tsx
index 7bb6c73ac2..bd5379ed02 100644
--- a/extensions/gdpr/js/src/forum/components/ErasureRequestsList.tsx
+++ b/extensions/gdpr/js/src/forum/components/ErasureRequestsList.tsx
@@ -59,7 +59,7 @@ export default class ErasureRequestsList
diff --git a/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx b/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx
index 7bdcb9c73a..00d6f96dea 100644
--- a/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx
+++ b/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx
@@ -2,6 +2,7 @@ import Form from 'flarum/common/components/Form';
import app from 'flarum/forum/app';
import type { IInternalModalAttrs } from 'flarum/common/components/Modal';
import Button from 'flarum/common/components/Button';
+import humanTime from 'flarum/common/helpers/humanTime';
import username from 'flarum/common/helpers/username';
import extractText from 'flarum/common/utils/extractText';
import ItemList from 'flarum/common/utils/ItemList';
@@ -60,6 +61,27 @@ export default class ProcessErasureRequestModal<
);
+ const createdAt = erasureRequest.createdAt();
+ const userConfirmedAt = erasureRequest.userConfirmedAt();
+
+ items.add(
+ 'timestamps',
+
+ {createdAt &&
{app.translator.trans('flarum-gdpr.forum.process_erasure.requested_at', { date: humanTime(createdAt) })}
}
+ {userConfirmedAt && (
+ <>
+
{app.translator.trans('flarum-gdpr.forum.process_erasure.confirmed_at', { date: humanTime(userConfirmedAt) })}
+
+ {app.translator.trans('flarum-gdpr.forum.process_erasure.eligible_at', {
+ date: humanTime(new Date(userConfirmedAt.getTime() + 30 * 24 * 60 * 60 * 1000)),
+ })}
+
+ >
+ )}
+
,
+ 90
+ );
+
erasureRequest?.reason() &&
items.add(
'reason',
diff --git a/extensions/gdpr/migrations/2026_03_06_000000_add_confirmation_ip_to_gdpr_erasure_table.php b/extensions/gdpr/migrations/2026_03_06_000000_add_confirmation_ip_to_gdpr_erasure_table.php
new file mode 100644
index 0000000000..14f7d1b3f2
--- /dev/null
+++ b/extensions/gdpr/migrations/2026_03_06_000000_add_confirmation_ip_to_gdpr_erasure_table.php
@@ -0,0 +1,28 @@
+ function (Builder $schema) {
+ if (! $schema->hasColumn('gdpr_erasure', 'confirmation_ip')) {
+ $schema->table('gdpr_erasure', function (Blueprint $table) {
+ $table->string('confirmation_ip', 45)->nullable();
+ });
+ }
+ },
+ 'down' => function (Builder $schema) {
+ if ($schema->hasColumn('gdpr_erasure', 'confirmation_ip')) {
+ $schema->table('gdpr_erasure', function (Blueprint $table) {
+ $table->dropColumn('confirmation_ip');
+ });
+ }
+ },
+];
diff --git a/extensions/gdpr/resources/locale/en.yml b/extensions/gdpr/resources/locale/en.yml
index 94b9428ab1..fe383445d5 100644
--- a/extensions/gdpr/resources/locale/en.yml
+++ b/extensions/gdpr/resources/locale/en.yml
@@ -160,6 +160,9 @@ flarum-gdpr:
comments_label: Comments (optional)
anonymization_button: Anonymize user
deletion_button: Delete user
+ requested_at: "Requested: {date}"
+ confirmed_at: "Confirmed: {date}"
+ eligible_at: "Eligible for auto-processing: {date}"
request_erasure:
title: Request account erasure
diff --git a/extensions/gdpr/src/Console/ClearConfirmationIps.php b/extensions/gdpr/src/Console/ClearConfirmationIps.php
new file mode 100644
index 0000000000..4cfd86923d
--- /dev/null
+++ b/extensions/gdpr/src/Console/ClearConfirmationIps.php
@@ -0,0 +1,31 @@
+whereNotNull('confirmation_ip')
+ ->whereNotNull('user_confirmed_at')
+ ->where('user_confirmed_at', '<=', Carbon::now()->subDays(static::RETENTION_DAYS))
+ ->update(['confirmation_ip' => null]);
+ }
+}
diff --git a/extensions/gdpr/src/Http/Controller/ConfirmErasureController.php b/extensions/gdpr/src/Http/Controller/ConfirmErasureController.php
index 214d988c1f..0a7bf1162c 100644
--- a/extensions/gdpr/src/Http/Controller/ConfirmErasureController.php
+++ b/extensions/gdpr/src/Http/Controller/ConfirmErasureController.php
@@ -45,9 +45,17 @@ public function handle(Request $request): ResponseInterface
throw new ValidationException(['user' => 'Erase requests cannot be confirmed by different users.']);
}
+ if (in_array($erasureRequest->status, [ErasureRequest::STATUS_PROCESSED, ErasureRequest::STATUS_MANUAL])) {
+ throw new ValidationException(['request' => 'This erasure request has already been processed.']);
+ }
+
+ $ip = $request->getAttribute('ipAddress');
+
$erasureRequest->user_confirmed_at = Carbon::now();
$erasureRequest->status = ErasureRequest::STATUS_USER_CONFIRMED;
$erasureRequest->cancelled_at = null;
+ $erasureRequest->verification_token = null;
+ $erasureRequest->confirmation_ip = $ip;
$erasureRequest->save();
return new RedirectResponse($this->url->to('forum')->base().'?erasureRequestConfirmed=1');
diff --git a/extensions/gdpr/src/Models/ErasureRequest.php b/extensions/gdpr/src/Models/ErasureRequest.php
index 84b1b81e61..c0498d087d 100644
--- a/extensions/gdpr/src/Models/ErasureRequest.php
+++ b/extensions/gdpr/src/Models/ErasureRequest.php
@@ -30,6 +30,7 @@
* @property Carbon|null $processed_at
* @property string|null $processed_mode
* @property Carbon|null $cancelled_at
+ * @property string|null $confirmation_ip
*/
class ErasureRequest extends AbstractModel
{
diff --git a/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php b/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php
new file mode 100644
index 0000000000..c58c4b4c9e
--- /dev/null
+++ b/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php
@@ -0,0 +1,71 @@
+extension('flarum-gdpr');
+
+ $this->prepareDatabase([
+ User::class => [
+ ['id' => 2, 'username' => 'user_old_ip', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'user_old_ip@machine.local', 'is_email_confirmed' => 1],
+ ['id' => 3, 'username' => 'user_recent_ip', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'user_recent_ip@machine.local', 'is_email_confirmed' => 1],
+ ['id' => 4, 'username' => 'user_no_ip', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'user_no_ip@machine.local', 'is_email_confirmed' => 1],
+ ],
+ 'gdpr_erasure' => [
+ // Old confirmed request with IP — should be cleared (91 days ago)
+ ['id' => 1, 'user_id' => 2, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(91), 'user_confirmed_at' => Carbon::now()->subDays(91), 'confirmation_ip' => '1.2.3.4'],
+ // Recent confirmed request with IP — should NOT be cleared (1 day ago)
+ ['id' => 2, 'user_id' => 3, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(1), 'user_confirmed_at' => Carbon::now()->subDays(1), 'confirmation_ip' => '5.6.7.8'],
+ // Old confirmed request without IP — unaffected
+ ['id' => 3, 'user_id' => 4, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(91), 'user_confirmed_at' => Carbon::now()->subDays(91), 'confirmation_ip' => null],
+ ],
+ ]);
+ }
+
+ #[Test]
+ public function ips_older_than_90_days_are_cleared()
+ {
+ $this->runCommand(['command' => 'gdpr:clear-confirmation-ips']);
+
+ $erasureRequest = ErasureRequest::query()->find(1);
+ $this->assertNull($erasureRequest->confirmation_ip);
+ }
+
+ #[Test]
+ public function recent_ips_are_not_cleared()
+ {
+ $this->runCommand(['command' => 'gdpr:clear-confirmation-ips']);
+
+ $erasureRequest = ErasureRequest::query()->find(2);
+ $this->assertEquals('5.6.7.8', $erasureRequest->confirmation_ip);
+ }
+
+ #[Test]
+ public function records_without_ip_are_unaffected()
+ {
+ $this->runCommand(['command' => 'gdpr:clear-confirmation-ips']);
+
+ $erasureRequest = ErasureRequest::query()->find(3);
+ $this->assertNull($erasureRequest->confirmation_ip);
+ // Status should be unchanged
+ $this->assertEquals('user_confirmed', $erasureRequest->status);
+ }
+}
diff --git a/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php b/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php
index 6c259d31a3..5fc3e9218b 100644
--- a/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php
+++ b/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php
@@ -38,9 +38,13 @@ public function setUp(): void
User::class => [
$this->normalUser(),
['id' => 3, 'username' => 'moderator', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'moderator@machine.local', 'is_email_confirmed' => 1],
+ ['id' => 4, 'username' => 'processed_user', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'processed@machine.local', 'is_email_confirmed' => 1],
+ ['id' => 5, 'username' => 'manual_user', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'manual@machine.local', 'is_email_confirmed' => 1],
],
'gdpr_erasure' => [
['id' => 1, 'user_id' => 2, 'verification_token' => 'abc123', 'status' => 'awaiting_user_confirmation', 'reason' => 'I want to be forgotten', 'created_at' => Carbon::now()],
+ ['id' => 2, 'user_id' => 4, 'verification_token' => 'processed-token', 'status' => 'processed', 'reason' => null, 'created_at' => Carbon::now()->subDays(35), 'user_confirmed_at' => Carbon::now()->subDays(35)],
+ ['id' => 3, 'user_id' => 5, 'verification_token' => 'manual-token', 'status' => 'manual', 'reason' => null, 'created_at' => Carbon::now()->subDays(35), 'user_confirmed_at' => Carbon::now()->subDays(35)],
],
]);
@@ -89,10 +93,11 @@ public function guest_can_confirm_erasure_with_correct_token()
$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('http://localhost?erasureRequestConfirmed=1', $response->getHeaderLine('Location'));
- $erasureRequest = ErasureRequest::query()->where('verification_token', 'abc123')->first();
+ $erasureRequest = ErasureRequest::query()->find(1);
$this->assertNotNull($erasureRequest);
$this->assertEquals('user_confirmed', $erasureRequest->status);
$this->assertNotNull($erasureRequest->user_confirmed_at);
+ $this->assertNull($erasureRequest->verification_token);
}
#[Test]
@@ -131,10 +136,53 @@ public function user_can_confirm_erasure_with_correct_token()
$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('http://localhost?erasureRequestConfirmed=1', $response->getHeaderLine('Location'));
- $erasureRequest = ErasureRequest::query()->where('verification_token', 'abc123')->first();
+ $erasureRequest = ErasureRequest::query()->find(1);
$this->assertNotNull($erasureRequest);
$this->assertEquals('user_confirmed', $erasureRequest->status);
$this->assertNotNull($erasureRequest->user_confirmed_at);
+ $this->assertNull($erasureRequest->verification_token);
+ }
+
+ #[Test]
+ public function confirmation_ip_is_stored()
+ {
+ $response = $this->send(
+ $this->request(
+ 'GET',
+ '/gdpr/erasure/confirm/abc123'
+ )->withAttribute('bypassCsrfToken', true)
+ );
+
+ $this->assertEquals(302, $response->getStatusCode());
+
+ $erasureRequest = ErasureRequest::query()->find(1);
+ $this->assertNotNull($erasureRequest->confirmation_ip);
+ }
+
+ #[Test]
+ public function processed_request_returns_422()
+ {
+ $response = $this->send(
+ $this->request(
+ 'GET',
+ '/gdpr/erasure/confirm/processed-token'
+ )->withAttribute('bypassCsrfToken', true)
+ );
+
+ $this->assertEquals(422, $response->getStatusCode());
+ }
+
+ #[Test]
+ public function manual_request_returns_422()
+ {
+ $response = $this->send(
+ $this->request(
+ 'GET',
+ '/gdpr/erasure/confirm/manual-token'
+ )->withAttribute('bypassCsrfToken', true)
+ );
+
+ $this->assertEquals(422, $response->getStatusCode());
}
#[Test]