Stop serving a self-deleted account's files, and let them go at once

Reported from the shared free instance. A client deleted their own account
on 2026-09-18. Their five files kept serving through share links with no
expiry for the whole 30-day grace period. Somebody who asked to leave
stayed published.

Rule 1: once an account is soft-deleted, its uploads are served to nobody
but staff. That covers the client scope (assignment, group, shared
folder), share links, the public listing, public comments and zips.
Staff keep them, because the grace period exists to undo a mistake.
Nothing is deleted and share links are kept, so a restored account is
served again. A withdrawn share link answers like a token that never
existed. In practice this only meets self-deleted accounts: an
administrator deleting an account that owns anything must already choose
to delete or reassign it.

Rule 2, new in Privacy settings: when someone deletes their own account,
their files are deleted "when the grace period ends" (the default, and
today's behaviour) or "right away". "Right away" uses
DeletedAccountContent's cascade: their own uploads, and their folders only
if nothing else is left inside. It runs in the same transaction as the
account delete. A platform can force "right away" through the new
ResolvingSelfDeletion hook. The screen then shows the choice as set by
the hosting plan instead of offering a switch.

A second setting decides whose deletion both rules apply to: any account
(the default) or clients only. A staff member's uploads are often the
organization's work for its clients.

The delete-account screen now says what happens to the files before the
person confirms. New strings are in all sixteen locales.
This commit is contained in:
ignacionelson
2026-09-24 16:55:21 -03:00
parent 4524b75c9d
commit bccf3d1f29
30 changed files with 804 additions and 35 deletions
@@ -8,7 +8,9 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Erasure\SelfDeletion;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Identity\StartPage;
use App\Modules\Identity\StartPages;
@@ -19,6 +21,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
@@ -67,10 +70,20 @@ class ProfileController extends Controller
* you scroll past on the way to saving your email address. The delete
* itself still goes to destroy() below.
*/
public function deleteAccount(): Response
public function deleteAccount(Request $request): Response
{
$user = $request->user();
$selfDeletion = app(SelfDeletion::class);
$applies = $user !== null && $selfDeletion->appliesTo($user);
return Inertia::render('settings/delete-account', [
'erasureGraceDays' => (int) app(Settings::class)->get(Setting::AccountErasureGraceDays),
// What happens to their files, said before they confirm. Both
// follow the account's own type (SelfDeletion::appliesTo), so a
// staff member on a "clients only" installation is told
// neither, because neither happens to them.
'filesWithdrawn' => $applies,
'filesDeletedImmediately' => $applies && $selfDeletion->deletesFilesImmediately(),
]);
}
@@ -132,10 +145,27 @@ class ProfileController extends Controller
// Self-deletion: soft delete now, permanent GDPR erasure after
// the disclosed grace period (Setting::AccountErasureGraceDays).
app(ErasureSchedule::class)->apply($user);
$user->delete();
//
// One transaction with the files, for the reason
// ClientsController::destroy gives: a deletion whose second half
// failed must not leave the account gone and the files it
// promised to delete still there.
DB::transaction(function () use ($user): void {
app(ErasureSchedule::class)->apply($user);
$user->delete();
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
// Only what they own, by the rule an administrator's delete
// uses: their uploads, and their folders only if nothing else
// is left inside them. See SelfDeletion.
$selfDeletion = app(SelfDeletion::class);
if ($selfDeletion->appliesTo($user) && $selfDeletion->deletesFilesImmediately()) {
$result = app(DeletedAccountContent::class)->cascadeDelete($user);
app(ActivityLogger::class)->log(Action::AccountContentCascadeDeleted, context: ['name' => $user->name, ...$result]);
}
});
$request->session()->invalidate();
$request->session()->regenerateToken();