From bccf3d1f29561eded0c878321041d83cc7bf16d1 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Thu, 24 Sep 2026 16:55:21 -0300 Subject: [PATCH] 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. --- .../Settings/ProfileController.php | 38 ++- .../PublicFileCommentsController.php | 2 +- .../Controllers/PublicShareController.php | 9 +- app/Modules/Files/Models/File.php | 40 ++- .../Controllers/PublicGroupsController.php | 8 +- .../Erasure/Events/ResolvingSelfDeletion.php | 44 +++ app/Modules/Identity/Erasure/SelfDeletion.php | 95 +++++++ .../Controllers/PrivacySettingsController.php | 21 +- app/Modules/Platform/Settings/Setting.php | 24 +- lang/ca.json | 13 +- lang/cs.json | 13 +- lang/de.json | 13 +- lang/es.json | 13 +- lang/fr.json | 13 +- lang/id.json | 13 +- lang/it.json | 13 +- lang/ja.json | 13 +- lang/nl.json | 13 +- lang/pl.json | 13 +- lang/pt_BR.json | 13 +- lang/ru.json | 13 +- lang/sw.json | 13 +- lang/tr.json | 13 +- lang/vi.json | 13 +- lang/zh_CN.json | 13 +- resources/js/components/delete-user.tsx | 20 +- .../js/pages/settings/delete-account.tsx | 12 +- .../js/pages/system/settings/privacy.tsx | 59 ++++ tests/Feature/Identity/SelfDeletionTest.php | 255 ++++++++++++++++++ .../Feature/Platform/PrivacySettingsTest.php | 4 + 30 files changed, 804 insertions(+), 35 deletions(-) create mode 100644 app/Modules/Identity/Erasure/Events/ResolvingSelfDeletion.php create mode 100644 app/Modules/Identity/Erasure/SelfDeletion.php create mode 100644 tests/Feature/Identity/SelfDeletionTest.php diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index 8d9bc4e8..f0355f29 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -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(); diff --git a/app/Modules/Comments/Http/Controllers/PublicFileCommentsController.php b/app/Modules/Comments/Http/Controllers/PublicFileCommentsController.php index d8298f9a..b8b5c8b4 100644 --- a/app/Modules/Comments/Http/Controllers/PublicFileCommentsController.php +++ b/app/Modules/Comments/Http/Controllers/PublicFileCommentsController.php @@ -125,7 +125,7 @@ class PublicFileCommentsController extends Controller private function guard(string $publicSlug, File $file): void { abort_unless($this->settings->get(Setting::PublicListingSlug) === $publicSlug, 404); - abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404); + abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404); // Same answer as the file's own public page, which 404s a file // that is not available: otherwise a pending or quarantined file // could be discussed, and found to exist, by anybody. diff --git a/app/Modules/Files/Http/Controllers/PublicShareController.php b/app/Modules/Files/Http/Controllers/PublicShareController.php index 4ff68313..4e030f22 100644 --- a/app/Modules/Files/Http/Controllers/PublicShareController.php +++ b/app/Modules/Files/Http/Controllers/PublicShareController.php @@ -41,7 +41,12 @@ class PublicShareController extends Controller $shareLink = ShareLink::query()->where('token', $token)->first(); $file = $shareLink?->shareable; - if ($shareLink === null || ! $file instanceof File) { + // A withdrawn file answers exactly as a link that never existed. + // Its uploader deleted their account, and "this was here once" is + // itself something they asked to stop saying. The link row stays, + // so an account that is restored is served again. See + // SelfDeletion. + if ($shareLink === null || ! $file instanceof File || $file->isWithdrawn()) { return Inertia::render('share/show', ['status' => 'not_found']); } @@ -99,7 +104,7 @@ class PublicShareController extends Controller $shareLink = ShareLink::query()->where('token', $token)->first(); $file = $shareLink?->shareable; - if ($shareLink === null || ! $file instanceof File || $shareLink->isExpired() || $file->isExpired()) { + if ($shareLink === null || ! $file instanceof File || $file->isWithdrawn() || $shareLink->isExpired() || $file->isExpired()) { return redirect()->route('share.show', $token); } diff --git a/app/Modules/Files/Models/File.php b/app/Modules/Files/Models/File.php index 14e9477b..b7d6ddc3 100644 --- a/app/Modules/Files/Models/File.php +++ b/app/Modules/Files/Models/File.php @@ -14,6 +14,7 @@ use App\Modules\Files\Scanning\NotScannedReason; use App\Modules\Files\Scanning\ScanStatus; use App\Modules\Files\Versions\FileVersions; use App\Modules\Groups\Models\Group; +use App\Modules\Identity\Erasure\SelfDeletion; use App\Support\Concerns\HasUniqueSlug; use Database\Factories\FileFactory; use Illuminate\Database\Eloquent\Builder; @@ -391,6 +392,36 @@ class File extends Model $query->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())); } + /** + * Files whose uploader has not deleted their own account — the first + * rule in SelfDeletion. Every surface that serves somebody other than + * staff narrows by this: the client scope, and the three public + * listing scopes. Single files ask isWithdrawn(). + * + * The null branch is not tidiness. `uploaded_by NOT IN (...)` is never + * true for a NULL uploader, so a file whose uploader was erased long + * ago would vanish from every client along with the withdrawn ones. + * + * @param Builder $query + */ + public function scopeNotWithdrawn(Builder $query): void + { + $query->where(fn (Builder $q) => $q + ->whereNull('uploaded_by') + ->orWhereNotIn('uploaded_by', app(SelfDeletion::class)->withdrawnAccounts())); + } + + /** + * The single-file twin of scopeNotWithdrawn(). Asked by the routes + * that reach one file without an account behind them — share links + * and the public listing — which have no client scope to lean on. + */ + public function isWithdrawn(): bool + { + return $this->uploaded_by !== null + && app(SelfDeletion::class)->withdrawnAccounts()->whereKey($this->uploaded_by)->exists(); + } + /** * Whether a cap has been set on how many times this may be * downloaded. Unlike expiry, reaching it does not hide the file: @@ -499,7 +530,9 @@ class File extends Model $outer->orWhere('uploaded_by', $client->id); }); - $query->notExpired()->available($client); + // Withdrawn last, beside expiry, because it is the same kind of + // rule: not "who may see this" but "may anybody besides staff". + $query->notExpired()->notWithdrawn()->available($client); } /** @@ -540,7 +573,7 @@ class File extends Model $outer->orWhereIn('folder_id', $subtreeFolderIds); }); - $query->notExpired()->available(); + $query->notExpired()->notWithdrawn()->available(); } /** @@ -554,7 +587,7 @@ class File extends Model */ public function scopePubliclyVisibleForFolder(Builder $query, Folder $folder): void { - $query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired()->available(); + $query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired()->notWithdrawn()->available(); } /** @@ -606,6 +639,7 @@ class File extends Model $folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicFolderSubtreeIds); }) ->notExpired() + ->notWithdrawn() ->available(); } } diff --git a/app/Modules/Groups/Http/Controllers/PublicGroupsController.php b/app/Modules/Groups/Http/Controllers/PublicGroupsController.php index 307e941c..ff6e1e92 100644 --- a/app/Modules/Groups/Http/Controllers/PublicGroupsController.php +++ b/app/Modules/Groups/Http/Controllers/PublicGroupsController.php @@ -194,7 +194,7 @@ class PublicGroupsController extends Controller { $this->guardSlug($publicSlug); - abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404); + abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404); abort_unless($this->availability->isAvailable($file), 404); $file->loadMissing('categories'); @@ -249,7 +249,7 @@ class PublicGroupsController extends Controller { $this->guardSlug($publicSlug); - abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404); + abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404); abort_unless($this->availability->isAvailable($file), 404); abort_unless(ThumbnailGenerator::supports($file->mime_type), 404); @@ -307,7 +307,7 @@ class PublicGroupsController extends Controller { $this->guardSlug($publicSlug); - abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404); + abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404); abort_unless($this->availability->isAvailable($file), 404); abort_unless($this->settings->get(Setting::PublicListingPreviewEnabled) === true, 404); abort_if(PreviewKind::forMime($file->mime_type) === null, 404); @@ -354,7 +354,7 @@ class PublicGroupsController extends Controller { $this->guardSlug($publicSlug); - abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404); + abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404); abort_unless($this->availability->isAvailable($file), 404); // 403 rather than 404, unlike the checks above it: the file is diff --git a/app/Modules/Identity/Erasure/Events/ResolvingSelfDeletion.php b/app/Modules/Identity/Erasure/Events/ResolvingSelfDeletion.php new file mode 100644 index 00000000..d405d109 --- /dev/null +++ b/app/Modules/Identity/Erasure/Events/ResolvingSelfDeletion.php @@ -0,0 +1,44 @@ +filesImmediately = true; + $this->managed = true; + } +} diff --git a/app/Modules/Identity/Erasure/SelfDeletion.php b/app/Modules/Identity/Erasure/SelfDeletion.php new file mode 100644 index 00000000..60967e8f --- /dev/null +++ b/app/Modules/Identity/Erasure/SelfDeletion.php @@ -0,0 +1,95 @@ +clientsOnly() ? $user->isClient() : true; + } + + public function deletesFilesImmediately(): bool + { + return $this->resolve()->filesImmediately; + } + + /** + * Whether the platform made the choice, so the settings screen shows + * it rather than a switch that would change nothing. + */ + public function isManaged(): bool + { + return $this->resolve()->managed; + } + + /** + * The ids of every deleted account whose files are withdrawn — the + * subquery File::scopeNotWithdrawn() and File::isWithdrawn() ask. + * + * @return Builder + */ + public function withdrawnAccounts(): Builder + { + return User::onlyTrashed() + ->select('id') + ->when($this->clientsOnly(), fn (Builder $query) => $query->where('type', UserType::Client)); + } + + private function clientsOnly(): bool + { + return $this->settings->get(Setting::AccountSelfDeleteScope) === 'clients'; + } + + private function resolve(): ResolvingSelfDeletion + { + $event = new ResolvingSelfDeletion( + $this->settings->get(Setting::AccountSelfDeleteFiles) === 'immediately', + ); + + Event::dispatch($event); + + return $event; + } +} diff --git a/app/Modules/Platform/Http/Controllers/PrivacySettingsController.php b/app/Modules/Platform/Http/Controllers/PrivacySettingsController.php index acc4af57..316ce603 100644 --- a/app/Modules/Platform/Http/Controllers/PrivacySettingsController.php +++ b/app/Modules/Platform/Http/Controllers/PrivacySettingsController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Modules\Audit\Action; use App\Modules\Audit\ActivityLogger; use App\Modules\Identity\AccountContentDeletion; +use App\Modules\Identity\Erasure\SelfDeletion; use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Settings; use Illuminate\Http\RedirectResponse; @@ -18,7 +19,8 @@ use Inertia\Response; /** * System-wide privacy settings (staff-only): download IP logging - * granularity, the account-erasure retention window, how long API request + * granularity, the account-erasure retention window, what deleting your + * own account does to your files (see SelfDeletion), how long API request * telemetry is kept, and whether to discourage search engines from * indexing this installation. */ @@ -28,6 +30,7 @@ class PrivacySettingsController extends Controller private readonly Settings $settings, private readonly ActivityLogger $activity, private readonly AccountContentDeletion $accountDeletion, + private readonly SelfDeletion $selfDeletion, ) {} public function edit(Request $request): Response @@ -41,6 +44,12 @@ class PrivacySettingsController extends Controller // erasure will use, stored once for everybody, and the page is // already behind edit_settings. 'reassign_candidates' => $this->accountDeletion->candidates(null), + // The effective answer, not the stored one: where a platform + // has made the choice, the screen shows what will happen and + // says who decided, instead of a switch that does nothing. + 'account_self_delete_files' => $this->selfDeletion->deletesFilesImmediately() ? 'immediately' : 'after_grace_period', + 'account_self_delete_files_managed' => $this->selfDeletion->isManaged(), + 'account_self_delete_scope' => $this->settings->get(Setting::AccountSelfDeleteScope), 'api_request_log_retention_days' => $this->settings->get(Setting::ApiRequestLogRetentionDays), 'discourage_search_indexing' => $this->settings->get(Setting::DiscourageSearchIndexing), ]); @@ -58,6 +67,8 @@ class PrivacySettingsController extends Controller 'required_if:account_erasure_content_action,reassign', Rule::exists('users', 'id')->where('active', true), ], + 'account_self_delete_files' => ['required', Rule::in(['after_grace_period', 'immediately'])], + 'account_self_delete_scope' => ['required', Rule::in(['any', 'clients'])], 'api_request_log_retention_days' => ['required', 'integer', 'min:0', 'max:3650'], 'discourage_search_indexing' => ['required', 'boolean'], ]); @@ -71,6 +82,14 @@ class PrivacySettingsController extends Controller Setting::AccountErasureReassignTo, $validated['account_erasure_content_action'] === 'reassign' ? (int) $validated['account_erasure_reassign_to'] : 0, ); + // Not written while a platform decides it. The screen shows that + // choice with the control disabled, so what comes back is only the + // platform's answer echoed; storing it would record a decision + // this installation never made. + if (! $this->selfDeletion->isManaged()) { + $this->settings->set(Setting::AccountSelfDeleteFiles, $validated['account_self_delete_files']); + } + $this->settings->set(Setting::AccountSelfDeleteScope, $validated['account_self_delete_scope']); $this->settings->set(Setting::ApiRequestLogRetentionDays, $validated['api_request_log_retention_days']); $this->settings->set(Setting::DiscourageSearchIndexing, $validated['discourage_search_indexing']); diff --git a/app/Modules/Platform/Settings/Setting.php b/app/Modules/Platform/Settings/Setting.php index 68ef5e93..9a5c254a 100644 --- a/app/Modules/Platform/Settings/Setting.php +++ b/app/Modules/Platform/Settings/Setting.php @@ -177,6 +177,22 @@ enum Setting: string // instead, so content is never left orphaned. case AccountErasureReassignTo = 'account_erasure_reassign_to'; + // When somebody deletes their own account, whether the files they + // uploaded go at once ('immediately') or wait for the grace period + // like the account does ('after_grace_period'). Only the self-service + // delete asks it: an administrator deleting an account already + // chooses per account. Read through SelfDeletion, which a hosted + // platform can overrule — never read it directly. + case AccountSelfDeleteFiles = 'account_self_delete_files'; + + // Whose self-deletion the two rules apply to: 'any' account, or + // 'clients' only. The rules are this setting's neighbour above, and + // that a self-deleted account's files stop being served to anybody + // but staff while the grace period runs. A staff member's uploads are + // often the organization's work for its clients, which is the case + // for narrowing it. Consumed by SelfDeletion. + case AccountSelfDeleteScope = 'account_self_delete_scope'; + // Whether PurgeExpiredFilesCommand's daily run actually deletes // anything (off by default — deletion is destructive, so an admin // must opt in) and how many days after a file's own expires_at it @@ -414,7 +430,9 @@ enum Setting: string self::NewsLastFetchedAt, self::CaptchaProvider, self::CaptchaKeySource, - self::AccountErasureContentAction => SettingType::String, + self::AccountErasureContentAction, + self::AccountSelfDeleteFiles, + self::AccountSelfDeleteScope => SettingType::String, self::ClientsCanRegister, self::ClientsAutoApprove, @@ -563,6 +581,10 @@ enum Setting: string // uploaded is the sensible zero-config default; reassign is opt-in // and needs a fallback account chosen. self::AccountErasureContentAction => 'cascade_delete', + // What self-deletion did before the choice existed, so an + // upgrade changes nothing about when files are deleted. + self::AccountSelfDeleteFiles => 'after_grace_period', + self::AccountSelfDeleteScope => 'any', // Commenting is on for every file out of the box, but only // between people who are logged in: reaching the public // requires PublicCommentsEnabled, which is off by default. diff --git a/lang/ca.json b/lang/ca.json index cd0996d5..9ad7164c 100644 --- a/lang/ca.json +++ b/lang/ca.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Gestionat pel proveïdor", "Temporary upload space": "Espai temporal per a pujades", "Uploads use temporary space while being assembled, including when files are stored externally.": "Les pujades fan servir espai temporal mentre es munten, també quan els fitxers es desen en un emmagatzematge extern.", - "Too many attempts. Wait a minute and try again.": "Massa intents. Espera un minut i torna-ho a provar." + "Too many attempts. Wait a minute and try again.": "Massa intents. Espera un minut i torna-ho a provar.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Els fitxers que has pujat s'eliminen tan bon punt confirmis. No es poden recuperar.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Els fitxers que has pujat deixen d'estar disponibles per a tothom amb qui els has compartit tan bon punt confirmis.", + "When someone deletes their own account, their files": "Quan algú elimina el seu propi compte, els seus fitxers", + "Are deleted when the grace period ends": "S'eliminen quan acaba el període de gràcia", + "Are deleted right away": "S'eliminen immediatament", + "Set by your hosting plan.": "Ho defineix el teu pla d'allotjament.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "En tots dos casos, des del moment que s'elimina el compte, ningú excepte l'equip pot veure ni baixar aquests fitxers. Només s'eliminen els fitxers que va pujar aquesta persona, i les seves carpetes només si no hi queda res més a dins.", + "This applies when": "Això s'aplica quan", + "Anyone deletes their own account": "Qualsevol persona elimina el seu propi compte", + "A client deletes their own account": "Un client elimina el seu propi compte", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "El que puja algú de l'equip sol ser feina de la teva organització per als seus clients. Tria només clients per continuar servint aquests fitxers fins que el compte s'esborri definitivament." } diff --git a/lang/cs.json b/lang/cs.json index 667abec4..e816761d 100644 --- a/lang/cs.json +++ b/lang/cs.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Spravuje poskytovatel", "Temporary upload space": "Dočasné místo pro nahrávání", "Uploads use temporary space while being assembled, including when files are stored externally.": "Nahrávané soubory se při skládání ukládají do dočasného místa, i když se soubory ukládají externě.", - "Too many attempts. Wait a minute and try again.": "Příliš mnoho pokusů. Počkejte minutu a zkuste to znovu." + "Too many attempts. Wait a minute and try again.": "Příliš mnoho pokusů. Počkejte minutu a zkuste to znovu.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Soubory, které jste nahráli, budou smazány hned po potvrzení. Nelze je obnovit.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Soubory, které jste nahráli, přestanou být dostupné všem, se kterými jste je sdíleli, hned po potvrzení.", + "When someone deletes their own account, their files": "Když někdo smaže svůj vlastní účet, jeho soubory", + "Are deleted when the grace period ends": "Budou smazány po skončení ochranné lhůty", + "Are deleted right away": "Budou smazány okamžitě", + "Set by your hosting plan.": "Nastaveno vaším hostingovým tarifem.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "V obou případech od chvíle smazání účtu nikdo kromě týmu nemůže tyto soubory zobrazit ani stáhnout. Mažou se jen soubory, které daný člověk nahrál, a jeho složky jen tehdy, pokud v nich nic jiného nezůstalo.", + "This applies when": "Platí to, když", + "Anyone deletes their own account": "Kdokoli smaže svůj vlastní účet", + "A client deletes their own account": "Klient smaže svůj vlastní účet", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "To, co nahraje člen týmu, je často práce vaší organizace pro její klienty. Zvolte jen klienty, pokud chcete tyto soubory dál poskytovat až do trvalého vymazání účtu." } diff --git a/lang/de.json b/lang/de.json index f71e4dcc..4c2c70c4 100644 --- a/lang/de.json +++ b/lang/de.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Vom Anbieter verwaltet", "Temporary upload space": "Temporärer Upload-Speicher", "Uploads use temporary space while being assembled, including when files are stored externally.": "Uploads belegen temporären Speicher, während sie zusammengesetzt werden – auch wenn Dateien extern gespeichert werden.", - "Too many attempts. Wait a minute and try again.": "Zu viele Versuche. Bitte warten Sie eine Minute und versuchen Sie es erneut." + "Too many attempts. Wait a minute and try again.": "Zu viele Versuche. Bitte warten Sie eine Minute und versuchen Sie es erneut.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Die von Ihnen hochgeladenen Dateien werden gelöscht, sobald Sie bestätigen. Sie können nicht wiederhergestellt werden.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Die von Ihnen hochgeladenen Dateien sind für alle, mit denen Sie sie geteilt haben, nicht mehr verfügbar, sobald Sie bestätigen.", + "When someone deletes their own account, their files": "Wenn jemand das eigene Konto löscht, werden die Dateien dieser Person", + "Are deleted when the grace period ends": "Nach Ablauf der Karenzzeit gelöscht", + "Are deleted right away": "Sofort gelöscht", + "Set by your hosting plan.": "Durch Ihren Hosting-Tarif festgelegt.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "In beiden Fällen kann ab dem Löschen des Kontos niemand außer dem Team diese Dateien sehen oder herunterladen. Gelöscht werden nur die eigenen Uploads der Person und ihre Ordner nur dann, wenn nichts anderes mehr darin liegt.", + "This applies when": "Dies gilt, wenn", + "Anyone deletes their own account": "Jemand das eigene Konto löscht", + "A client deletes their own account": "Ein Kunde das eigene Konto löscht", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Uploads von Teammitgliedern sind oft Arbeit Ihrer Organisation für ihre Kunden. Wählen Sie „nur Kunden“, damit diese Dateien bis zur endgültigen Löschung des Kontos weiter bereitgestellt werden." } diff --git a/lang/es.json b/lang/es.json index 21b68d40..af3f21cc 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2264,5 +2264,16 @@ "Storage available": "Espacio disponible", "Managed by provider": "Gestionado por el proveedor", "Temporary upload space": "Espacio temporal para subidas", - "Uploads use temporary space while being assembled, including when files are stored externally.": "Las subidas usan espacio temporal mientras se ensamblan, también cuando los archivos se guardan en un almacenamiento externo." + "Uploads use temporary space while being assembled, including when files are stored externally.": "Las subidas usan espacio temporal mientras se ensamblan, también cuando los archivos se guardan en un almacenamiento externo.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Los archivos que subiste se eliminan en cuanto confirmes. No se pueden recuperar.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Los archivos que subiste dejan de estar disponibles para todas las personas con quienes los compartiste en cuanto confirmes.", + "When someone deletes their own account, their files": "Cuando alguien elimina su propia cuenta, sus archivos", + "Are deleted when the grace period ends": "Se eliminan al terminar el período de gracia", + "Are deleted right away": "Se eliminan en el momento", + "Set by your hosting plan.": "Lo define tu plan de alojamiento.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "En ambos casos, desde que se elimina la cuenta, nadie salvo el personal puede ver ni descargar esos archivos. Solo se eliminan los archivos que subió esa persona, y sus carpetas solo si no queda nada más dentro.", + "This applies when": "Esto se aplica cuando", + "Anyone deletes their own account": "Cualquier persona elimina su propia cuenta", + "A client deletes their own account": "Un cliente elimina su propia cuenta", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Lo que sube alguien del personal suele ser trabajo de tu organización para sus clientes. Elige solo clientes para seguir sirviendo esos archivos hasta que la cuenta se borre definitivamente." } diff --git a/lang/fr.json b/lang/fr.json index 6ed278e0..73dae3cb 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Géré par le fournisseur", "Temporary upload space": "Espace temporaire des envois", "Uploads use temporary space while being assembled, including when files are stored externally.": "Les envois utilisent un espace temporaire pendant leur assemblage, même lorsque les fichiers sont stockés en externe.", - "Too many attempts. Wait a minute and try again.": "Trop de tentatives. Veuillez patienter une minute et réessayer." + "Too many attempts. Wait a minute and try again.": "Trop de tentatives. Veuillez patienter une minute et réessayer.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Les fichiers que vous avez envoyés sont supprimés dès que vous confirmez. Ils ne pourront pas être récupérés.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Les fichiers que vous avez envoyés ne sont plus accessibles aux personnes avec qui vous les avez partagés dès que vous confirmez.", + "When someone deletes their own account, their files": "Quand quelqu'un supprime son propre compte, ses fichiers", + "Are deleted when the grace period ends": "Sont supprimés à la fin du délai de grâce", + "Are deleted right away": "Sont supprimés immédiatement", + "Set by your hosting plan.": "Défini par votre offre d'hébergement.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Dans les deux cas, dès la suppression du compte, personne d'autre que l'équipe ne peut voir ni télécharger ces fichiers. Seuls les fichiers envoyés par cette personne sont supprimés, et ses dossiers seulement s'il n'y reste rien d'autre.", + "This applies when": "Cela s'applique quand", + "Anyone deletes their own account": "N'importe qui supprime son propre compte", + "A client deletes their own account": "Un client supprime son propre compte", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Les envois d'un membre de l'équipe sont souvent le travail de votre organisation pour ses clients. Choisissez « clients uniquement » pour continuer à servir ces fichiers jusqu'à l'effacement du compte." } diff --git a/lang/id.json b/lang/id.json index ca757600..9638bd9b 100644 --- a/lang/id.json +++ b/lang/id.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Dikelola oleh penyedia", "Temporary upload space": "Ruang unggahan sementara", "Uploads use temporary space while being assembled, including when files are stored externally.": "Unggahan memakai ruang sementara selama dirakit, termasuk saat berkas disimpan di penyimpanan eksternal.", - "Too many attempts. Wait a minute and try again.": "Terlalu banyak percobaan. Tunggu satu menit lalu coba lagi." + "Too many attempts. Wait a minute and try again.": "Terlalu banyak percobaan. Tunggu satu menit lalu coba lagi.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "File yang Anda unggah dihapus begitu Anda mengonfirmasi. File tersebut tidak dapat dipulihkan.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "File yang Anda unggah tidak lagi tersedia bagi semua orang yang Anda bagikan begitu Anda mengonfirmasi.", + "When someone deletes their own account, their files": "Saat seseorang menghapus akunnya sendiri, file miliknya", + "Are deleted when the grace period ends": "Dihapus saat masa tenggang berakhir", + "Are deleted right away": "Langsung dihapus", + "Set by your hosting plan.": "Diatur oleh paket hosting Anda.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Dalam kedua kasus, sejak akun dihapus, tidak ada yang selain staf dapat melihat atau mengunduh file tersebut. Hanya file unggahan orang itu sendiri yang dihapus, dan foldernya hanya jika tidak ada isi lain yang tersisa.", + "This applies when": "Ini berlaku saat", + "Anyone deletes their own account": "Siapa pun menghapus akunnya sendiri", + "A client deletes their own account": "Klien menghapus akunnya sendiri", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Unggahan staf sering kali merupakan pekerjaan organisasi Anda untuk kliennya. Pilih hanya klien agar file tersebut tetap tersedia hingga akun dihapus permanen." } diff --git a/lang/it.json b/lang/it.json index 9fa0b5f5..48575864 100644 --- a/lang/it.json +++ b/lang/it.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Gestito dal provider", "Temporary upload space": "Spazio temporaneo per i caricamenti", "Uploads use temporary space while being assembled, including when files are stored externally.": "I caricamenti usano spazio temporaneo mentre vengono assemblati, anche quando i file sono archiviati esternamente.", - "Too many attempts. Wait a minute and try again.": "Troppi tentativi. Aspetta un minuto e riprova." + "Too many attempts. Wait a minute and try again.": "Troppi tentativi. Aspetta un minuto e riprova.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "I file che hai caricato vengono eliminati non appena confermi. Non potranno essere recuperati.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "I file che hai caricato non sono più disponibili per le persone con cui li hai condivisi non appena confermi.", + "When someone deletes their own account, their files": "Quando qualcuno elimina il proprio account, i suoi file", + "Are deleted when the grace period ends": "Vengono eliminati al termine del periodo di tolleranza", + "Are deleted right away": "Vengono eliminati subito", + "Set by your hosting plan.": "Stabilito dal tuo piano di hosting.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "In entrambi i casi, dal momento in cui l'account viene eliminato, nessuno tranne lo staff può vedere o scaricare quei file. Vengono eliminati solo i file caricati da quella persona, e le sue cartelle solo se al loro interno non resta nient'altro.", + "This applies when": "Si applica quando", + "Anyone deletes their own account": "Chiunque elimina il proprio account", + "A client deletes their own account": "Un cliente elimina il proprio account", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "I caricamenti di un membro dello staff sono spesso lavoro della tua organizzazione per i suoi clienti. Scegli solo clienti per continuare a servire quei file finché l'account non viene cancellato." } diff --git a/lang/ja.json b/lang/ja.json index 82966eb7..32edea94 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -2264,5 +2264,16 @@ "Managed by provider": "プロバイダーが管理", "Temporary upload space": "アップロード用の一時領域", "Uploads use temporary space while being assembled, including when files are stored externally.": "アップロードは組み立ての間、一時領域を使用します。ファイルを外部ストレージに保存している場合も同様です。", - "Too many attempts. Wait a minute and try again.": "試行回数が多すぎます。1 分待ってからもう一度お試しください。" + "Too many attempts. Wait a minute and try again.": "試行回数が多すぎます。1 分待ってからもう一度お試しください。", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "アップロードしたファイルは確認した時点で削除されます。元に戻すことはできません。", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "アップロードしたファイルは、確認した時点で共有相手の誰からも利用できなくなります。", + "When someone deletes their own account, their files": "ユーザーが自分のアカウントを削除したとき、そのファイルは", + "Are deleted when the grace period ends": "猶予期間の終了時に削除する", + "Are deleted right away": "すぐに削除する", + "Set by your hosting plan.": "ご利用のホスティングプランで設定されています。", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "どちらの場合も、アカウントが削除された時点から、スタッフ以外はそのファイルを表示もダウンロードもできません。削除されるのはその人がアップロードしたファイルだけで、フォルダーは中に他に何も残っていない場合にのみ削除されます。", + "This applies when": "適用対象", + "Anyone deletes their own account": "誰かが自分のアカウントを削除したとき", + "A client deletes their own account": "クライアントが自分のアカウントを削除したとき", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "スタッフのアップロードは、多くの場合、組織がクライアントのために作成したものです。クライアントのみを選ぶと、アカウントが消去されるまでそれらのファイルを引き続き提供します。" } diff --git a/lang/nl.json b/lang/nl.json index ad5f3553..3575ab6f 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Beheerd door de provider", "Temporary upload space": "Tijdelijke uploadruimte", "Uploads use temporary space while being assembled, including when files are stored externally.": "Uploads gebruiken tijdelijke ruimte terwijl ze worden samengevoegd, ook als bestanden extern worden opgeslagen.", - "Too many attempts. Wait a minute and try again.": "Te veel pogingen. Wacht een minuut en probeer het opnieuw." + "Too many attempts. Wait a minute and try again.": "Te veel pogingen. Wacht een minuut en probeer het opnieuw.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "De bestanden die je hebt geüpload worden verwijderd zodra je bevestigt. Ze kunnen niet worden hersteld.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "De bestanden die je hebt geüpload zijn niet meer beschikbaar voor iedereen met wie je ze hebt gedeeld zodra je bevestigt.", + "When someone deletes their own account, their files": "Als iemand zijn eigen account verwijdert, worden diens bestanden", + "Are deleted when the grace period ends": "Verwijderd als de respijtperiode afloopt", + "Are deleted right away": "Direct verwijderd", + "Set by your hosting plan.": "Ingesteld door je hostingabonnement.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "In beide gevallen kan vanaf het moment dat het account is verwijderd niemand behalve het team die bestanden zien of downloaden. Alleen de eigen uploads worden verwijderd, en de eigen mappen alleen als er verder niets meer in staat.", + "This applies when": "Dit geldt als", + "Anyone deletes their own account": "Iemand zijn eigen account verwijdert", + "A client deletes their own account": "Een klant zijn eigen account verwijdert", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Uploads van teamleden zijn vaak werk van je organisatie voor haar klanten. Kies alleen klanten om die bestanden te blijven aanbieden tot het account definitief is gewist." } diff --git a/lang/pl.json b/lang/pl.json index 4e29ecab..1dd4fd9d 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Zarządzane przez dostawcę", "Temporary upload space": "Tymczasowe miejsce na przesyłanie", "Uploads use temporary space while being assembled, including when files are stored externally.": "Przesyłane pliki zajmują tymczasowe miejsce podczas składania, także gdy pliki są przechowywane w magazynie zewnętrznym.", - "Too many attempts. Wait a minute and try again.": "Zbyt wiele prób. Odczekaj minutę i spróbuj ponownie." + "Too many attempts. Wait a minute and try again.": "Zbyt wiele prób. Odczekaj minutę i spróbuj ponownie.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Przesłane przez ciebie pliki zostaną usunięte, gdy tylko potwierdzisz. Nie da się ich odzyskać.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Przesłane przez ciebie pliki przestaną być dostępne dla wszystkich, którym je udostępniono, gdy tylko potwierdzisz.", + "When someone deletes their own account, their files": "Gdy ktoś usuwa własne konto, jego pliki", + "Are deleted when the grace period ends": "Zostają usunięte po upływie okresu karencji", + "Are deleted right away": "Zostają usunięte od razu", + "Set by your hosting plan.": "Ustalone przez twój plan hostingowy.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "W obu przypadkach od chwili usunięcia konta nikt poza zespołem nie może zobaczyć ani pobrać tych plików. Usuwane są tylko pliki przesłane przez tę osobę, a jej foldery tylko wtedy, gdy nic innego w nich nie zostało.", + "This applies when": "Dotyczy to sytuacji, gdy", + "Anyone deletes their own account": "Ktokolwiek usuwa własne konto", + "A client deletes their own account": "Klient usuwa własne konto", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "To, co przesyłają członkowie zespołu, to często praca twojej organizacji dla jej klientów. Wybierz tylko klientów, aby nadal udostępniać te pliki aż do trwałego wymazania konta." } diff --git a/lang/pt_BR.json b/lang/pt_BR.json index 4e23966e..54fd19e3 100644 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Gerenciado pelo provedor", "Temporary upload space": "Espaço temporário de envio", "Uploads use temporary space while being assembled, including when files are stored externally.": "Os envios usam espaço temporário enquanto são montados, inclusive quando os arquivos ficam em um armazenamento externo.", - "Too many attempts. Wait a minute and try again.": "Tentativas demais. Aguarde um minuto e tente novamente." + "Too many attempts. Wait a minute and try again.": "Tentativas demais. Aguarde um minuto e tente novamente.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Os arquivos que você enviou são excluídos assim que você confirmar. Não será possível recuperá-los.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Os arquivos que você enviou deixam de estar disponíveis para todas as pessoas com quem você os compartilhou assim que você confirmar.", + "When someone deletes their own account, their files": "Quando alguém exclui a própria conta, os arquivos dessa pessoa", + "Are deleted when the grace period ends": "São excluídos ao fim do período de carência", + "Are deleted right away": "São excluídos na hora", + "Set by your hosting plan.": "Definido pelo seu plano de hospedagem.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Em ambos os casos, a partir do momento em que a conta é excluída, ninguém além da equipe pode ver ou baixar esses arquivos. Só são excluídos os arquivos enviados por essa pessoa, e as pastas dela apenas se não sobrar mais nada dentro.", + "This applies when": "Isso vale quando", + "Anyone deletes their own account": "Qualquer pessoa exclui a própria conta", + "A client deletes their own account": "Um cliente exclui a própria conta", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "O que um membro da equipe envia costuma ser trabalho da sua organização para os clientes. Escolha apenas clientes para continuar servindo esses arquivos até a conta ser apagada." } diff --git a/lang/ru.json b/lang/ru.json index f22c4362..b3a9c192 100644 --- a/lang/ru.json +++ b/lang/ru.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Управляется провайдером", "Temporary upload space": "Временное место для загрузок", "Uploads use temporary space while being assembled, including when files are stored externally.": "Во время сборки загрузки занимают временное место, даже если файлы хранятся во внешнем хранилище.", - "Too many attempts. Wait a minute and try again.": "Слишком много попыток. Подождите минуту и попробуйте ещё раз." + "Too many attempts. Wait a minute and try again.": "Слишком много попыток. Подождите минуту и попробуйте ещё раз.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Загруженные вами файлы будут удалены сразу после подтверждения. Восстановить их будет невозможно.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Загруженные вами файлы перестанут быть доступны всем, с кем вы ими поделились, сразу после подтверждения.", + "When someone deletes their own account, their files": "Когда кто-то удаляет свою учётную запись, его файлы", + "Are deleted when the grace period ends": "Удаляются по окончании отсрочки", + "Are deleted right away": "Удаляются сразу", + "Set by your hosting plan.": "Задаётся вашим тарифом хостинга.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "В обоих случаях с момента удаления учётной записи никто, кроме команды, не может просматривать или скачивать эти файлы. Удаляются только файлы, загруженные этим человеком, а его папки — только если в них больше ничего не осталось.", + "This applies when": "Это применяется, когда", + "Anyone deletes their own account": "Кто угодно удаляет свою учётную запись", + "A client deletes their own account": "Клиент удаляет свою учётную запись", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Загрузки участников команды — часто работа вашей организации для её клиентов. Выберите «только клиенты», чтобы эти файлы оставались доступны до окончательного удаления учётной записи." } diff --git a/lang/sw.json b/lang/sw.json index cfbdef53..e0164864 100644 --- a/lang/sw.json +++ b/lang/sw.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Inasimamiwa na mtoa huduma", "Temporary upload space": "Nafasi ya muda ya kupakia", "Uploads use temporary space while being assembled, including when files are stored externally.": "Mafaili yanayopakiwa hutumia nafasi ya muda yanapounganishwa, hata mafaili yanapohifadhiwa nje.", - "Too many attempts. Wait a minute and try again.": "Majaribio mengi mno. Subiri dakika moja kisha jaribu tena." + "Too many attempts. Wait a minute and try again.": "Majaribio mengi mno. Subiri dakika moja kisha jaribu tena.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Faili ulizopakia zinafutwa mara tu unapothibitisha. Haziwezi kurejeshwa.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Faili ulizopakia hazipatikani tena kwa kila mtu uliyeshiriki naye mara tu unapothibitisha.", + "When someone deletes their own account, their files": "Mtu anapofuta akaunti yake mwenyewe, faili zake", + "Are deleted when the grace period ends": "Hufutwa muda wa neema unapoisha", + "Are deleted right away": "Hufutwa papo hapo", + "Set by your hosting plan.": "Imewekwa na mpango wako wa upangishaji.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Kwa vyovyote vile, tangu akaunti inapofutwa, hakuna mtu isipokuwa wafanyakazi anayeweza kuona au kupakua faili hizo. Hufutwa faili alizopakia mtu huyo pekee, na folda zake ikiwa tu hakuna kitu kingine kilichobaki ndani.", + "This applies when": "Hii inatumika wakati", + "Anyone deletes their own account": "Mtu yeyote anafuta akaunti yake mwenyewe", + "A client deletes their own account": "Mteja anafuta akaunti yake mwenyewe", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Faili zinazopakiwa na wafanyakazi mara nyingi ni kazi ya shirika lako kwa wateja wake. Chagua wateja pekee ili faili hizo ziendelee kupatikana hadi akaunti ifutwe kabisa." } diff --git a/lang/tr.json b/lang/tr.json index 35a95d08..6d265434 100644 --- a/lang/tr.json +++ b/lang/tr.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Sağlayıcı tarafından yönetilir", "Temporary upload space": "Geçici yükleme alanı", "Uploads use temporary space while being assembled, including when files are stored externally.": "Yüklemeler birleştirilirken geçici alan kullanır; dosyalar harici depolamada tutulduğunda da.", - "Too many attempts. Wait a minute and try again.": "Çok fazla deneme. Lütfen bir dakika bekleyip yeniden deneyin." + "Too many attempts. Wait a minute and try again.": "Çok fazla deneme. Lütfen bir dakika bekleyip yeniden deneyin.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Yüklediğiniz dosyalar onayladığınız anda silinir. Geri getirilemez.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Yüklediğiniz dosyalar, onayladığınız anda paylaştığınız herkes için kullanılamaz hale gelir.", + "When someone deletes their own account, their files": "Biri kendi hesabını sildiğinde, dosyaları", + "Are deleted when the grace period ends": "Bekleme süresi bitince silinir", + "Are deleted right away": "Hemen silinir", + "Set by your hosting plan.": "Barındırma planınız tarafından belirlenir.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Her iki durumda da hesap silindiği andan itibaren ekip dışında kimse bu dosyaları göremez veya indiremez. Yalnızca o kişinin yüklediği dosyalar silinir; klasörleri ise yalnızca içinde başka bir şey kalmadıysa silinir.", + "This applies when": "Şu durumda geçerlidir", + "Anyone deletes their own account": "Herhangi biri kendi hesabını sildiğinde", + "A client deletes their own account": "Bir müşteri kendi hesabını sildiğinde", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Ekip üyelerinin yüklediği dosyalar çoğu zaman kuruluşunuzun müşterileri için yaptığı iştir. Hesap tamamen silinene kadar bu dosyaları sunmaya devam etmek için yalnızca müşterileri seçin." } diff --git a/lang/vi.json b/lang/vi.json index 45093071..f2e92215 100644 --- a/lang/vi.json +++ b/lang/vi.json @@ -2264,5 +2264,16 @@ "Managed by provider": "Do nhà cung cấp quản lý", "Temporary upload space": "Dung lượng tải lên tạm thời", "Uploads use temporary space while being assembled, including when files are stored externally.": "Khi tải lên, tệp dùng dung lượng tạm thời trong lúc được ghép lại, kể cả khi tệp được lưu ở bộ lưu trữ bên ngoài.", - "Too many attempts. Wait a minute and try again.": "Bạn đã thử quá nhiều lần. Hãy đợi một phút rồi thử lại." + "Too many attempts. Wait a minute and try again.": "Bạn đã thử quá nhiều lần. Hãy đợi một phút rồi thử lại.", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Các tệp bạn đã tải lên sẽ bị xóa ngay khi bạn xác nhận. Không thể khôi phục lại.", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Các tệp bạn đã tải lên sẽ không còn khả dụng với bất kỳ ai bạn đã chia sẻ ngay khi bạn xác nhận.", + "When someone deletes their own account, their files": "Khi ai đó tự xóa tài khoản của mình, các tệp của họ", + "Are deleted when the grace period ends": "Bị xóa khi hết thời gian chờ", + "Are deleted right away": "Bị xóa ngay lập tức", + "Set by your hosting plan.": "Do gói lưu trữ của bạn quy định.", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Dù chọn cách nào, kể từ lúc tài khoản bị xóa, không ai ngoài nhân sự có thể xem hay tải xuống các tệp đó. Chỉ những tệp do chính người đó tải lên bị xóa, và thư mục của họ chỉ bị xóa nếu bên trong không còn gì khác.", + "This applies when": "Áp dụng khi", + "Anyone deletes their own account": "Bất kỳ ai tự xóa tài khoản của mình", + "A client deletes their own account": "Một khách hàng tự xóa tài khoản của mình", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Tệp do nhân sự tải lên thường là công việc của tổ chức bạn dành cho khách hàng. Chọn chỉ khách hàng để tiếp tục cung cấp các tệp đó cho đến khi tài khoản bị xóa vĩnh viễn." } diff --git a/lang/zh_CN.json b/lang/zh_CN.json index 65629814..107836f3 100644 --- a/lang/zh_CN.json +++ b/lang/zh_CN.json @@ -2264,5 +2264,16 @@ "Managed by provider": "由服务商管理", "Temporary upload space": "上传临时空间", "Uploads use temporary space while being assembled, including when files are stored externally.": "上传的文件在合并期间会占用临时空间,即使文件存储在外部存储中也是如此。", - "Too many attempts. Wait a minute and try again.": "尝试次数过多,请等待一分钟后重试。" + "Too many attempts. Wait a minute and try again.": "尝试次数过多,请等待一分钟后重试。", + "The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "你上传的文件会在你确认后立即删除,且无法恢复。", + "The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "你确认后,你上传的文件将立即对所有与你共享的人不可用。", + "When someone deletes their own account, their files": "当有人删除自己的账户时,其文件", + "Are deleted when the grace period ends": "在宽限期结束时删除", + "Are deleted right away": "立即删除", + "Set by your hosting plan.": "由你的托管方案设定。", + "Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "无论选择哪种,从账户被删除的那一刻起,除团队成员外,任何人都无法查看或下载这些文件。只会删除此人自己上传的文件,其文件夹仅在里面不再有其他内容时才会删除。", + "This applies when": "适用情形", + "Anyone deletes their own account": "任何人删除自己的账户", + "A client deletes their own account": "客户删除自己的账户", + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "团队成员上传的文件通常是你的组织为客户完成的工作。选择仅限客户,可在账户被彻底抹除前继续提供这些文件。" } diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index 8f3a4ab2..f5d12028 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -12,8 +12,24 @@ import { useTranslation } from '@/hooks/use-translation'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; -export default function DeleteUser({ graceDays }: { graceDays: number }) { +interface DeleteUserProps { + graceDays: number; + /** Their files stop being served to anybody but staff at once. */ + filesWithdrawn: boolean; + /** Their files are deleted at once rather than with the account. */ + filesDeletedImmediately: boolean; +} + +export default function DeleteUser({ graceDays, filesWithdrawn, filesDeletedImmediately }: DeleteUserProps) { const { t } = useTranslation(); + + // What happens to the files they uploaded, said before they confirm. + // Deleted outranks withdrawn: a file that is gone is also not served. + const filesNotice = filesDeletedImmediately + ? t('The files you uploaded are deleted as soon as you confirm. They cannot be recovered.') + : filesWithdrawn + ? t('The files you uploaded stop being available to everyone you shared them with as soon as you confirm.') + : null; const passwordInput = useRef(null); const { data, setData, delete: destroy, processing, reset, errors, clearErrors } = useForm({ password: '' }); @@ -48,6 +64,7 @@ export default function DeleteUser({ graceDays }: { graceDays: number }) { { days: graceDays }, )}

+ {filesNotice !== null &&

{filesNotice}

} @@ -61,6 +78,7 @@ export default function DeleteUser({ graceDays }: { graceDays: number }) { days: graceDays, })} + {filesDeletedImmediately &&

{filesNotice}

}
)} +
+ + + + +

+ {account_self_delete_files_managed + ? t('Set by your hosting plan.') + : t( + 'Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.', + )} +

+ + +
+ +
+ + + + +

+ {t( + "A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.", + )} +

+ + +
+
admin = User::factory()->create(); + + // Settings survive the per-test rollback in the cache, so every one + // these tests lean on is set rather than assumed. + $settings = app(Settings::class); + $settings->set(Setting::AccountSelfDeleteFiles, 'after_grace_period'); + $settings->set(Setting::AccountSelfDeleteScope, 'any'); + $settings->set(Setting::AccountErasureGraceDays, 30); + $settings->set(Setting::PublicListingEnabled, true); + $settings->set(Setting::PublicListingSlug, 'public'); + $settings->set(Setting::Theme, 'default'); +}); + +function selfDeletionFile(User $uploader, array $overrides = []): File +{ + $file = File::factory()->create(array_merge(['uploaded_by' => $uploader->id], $overrides)); + Storage::disk('files')->put($file->path, 'bytes'); + + return $file; +} + +function selfDeletionShareLink(File $file): string +{ + $token = Str::random(32); + ShareLink::query()->create(['shareable_type' => $file->getMorphClass(), 'shareable_id' => $file->id, 'token' => $token]); + + return $token; +} + +function selfDeleteAccount(User $user): void +{ + test()->actingAs($user)->delete('/settings/profile', ['password' => 'password'])->assertRedirect('/'); + auth()->logout(); +} + +test('a self-deleted client\'s files stop being served to anybody but staff', function () { + $owner = User::factory()->client()->create(); + $recipient = User::factory()->client()->create(); + $file = selfDeletionFile($owner, ['public' => true]); + $this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $recipient->id]); + $token = selfDeletionShareLink($file); + + // Served everywhere first, so what follows is the deletion's doing. + $this->actingAs($recipient)->get("/files/{$file->id}/download")->assertOk(); + auth()->logout(); + $this->get("/s/{$token}")->assertInertia(fn ($page) => $page->where('status', 'active')); + $this->get("/public/files/{$file->slug}")->assertOk(); + + selfDeleteAccount($owner); + + $this->actingAs($recipient)->get("/files/{$file->id}/download")->assertForbidden(); + $this->actingAs($recipient)->get("/files/{$file->id}/thumbnail")->assertForbidden(); + $this->actingAs($recipient)->get('/my-files') + ->assertInertia(fn ($page) => $page->where('files', fn ($files) => collect($files)->pluck('id')->doesntContain($file->id))); + auth()->logout(); + + // Exactly what a link that never existed answers. + $this->get("/s/{$token}")->assertInertia(fn ($page) => $page->where('status', 'not_found')); + $this->get("/s/{$token}/download")->assertRedirect(route('share.show', $token)); + + $this->get("/public/files/{$file->slug}")->assertNotFound(); + $this->get("/public/files/{$file->slug}/download")->assertNotFound(); + $this->get("/public/files/{$file->slug}/comments")->assertNotFound(); + $this->get('/public')->assertInertia(fn ($page) => $page->where('files', [])); + + // Staff keep them: the grace period is for undoing a mistake. + $this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertOk(); + + // Nothing was deleted to get here. + expect(File::query()->find($file->id))->not->toBeNull() + ->and(ShareLink::query()->where('token', $token)->exists())->toBeTrue(); +}); + +test('group and shared-folder access is withdrawn too', function () { + $owner = User::factory()->client()->create(); + $member = User::factory()->client()->create(); + $group = Group::query()->create(['name' => 'Team']); + $group->members()->attach($member->id); + + $folder = Folder::query()->create(['name' => 'Shared']); + $this->actingAs($this->admin)->post("/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $member->id]); + $inFolder = selfDeletionFile($owner, ['folder_id' => $folder->id]); + + $viaGroup = selfDeletionFile($owner); + $this->actingAs($this->admin)->post("/files/{$viaGroup->id}/assignments", ['type' => 'group', 'id' => $group->id]); + + selfDeleteAccount($owner); + + $this->actingAs($member)->get("/files/{$inFolder->id}/download")->assertForbidden(); + $this->actingAs($member)->get("/files/{$viaGroup->id}/download")->assertForbidden(); +}); + +test('restoring the account serves its files again', function () { + $owner = User::factory()->client()->create(); + $file = selfDeletionFile($owner); + $token = selfDeletionShareLink($file); + + selfDeleteAccount($owner); + $this->get("/s/{$token}")->assertInertia(fn ($page) => $page->where('status', 'not_found')); + + User::withTrashed()->findOrFail($owner->id)->restore(); + + $this->get("/s/{$token}")->assertInertia(fn ($page) => $page->where('status', 'active')); +}); + +test('a file whose uploader was erased long ago is still served', function () { + // The null branch of notWithdrawn(): NOT IN never matches a NULL — + // once the list has anything in it. An empty list matches everything, + // NULL included, which is why somebody else is deleted first. + selfDeleteAccount(User::factory()->client()->create()); + $recipient = User::factory()->client()->create(); + $file = selfDeletionFile($this->admin); + $file->forceFill(['uploaded_by' => null])->save(); + $this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $recipient->id]); + + $this->actingAs($recipient)->get("/files/{$file->id}/download")->assertOk(); +}); + +test('with the clients-only scope, a staff member deleting their account leaves their uploads served', function () { + $staff = User::factory()->create(); + $recipient = User::factory()->client()->create(); + $file = selfDeletionFile($staff); + $this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $recipient->id]); + + app(Settings::class)->set(Setting::AccountSelfDeleteScope, 'clients'); + selfDeleteAccount($staff); + + $this->actingAs($recipient)->get("/files/{$file->id}/download")->assertOk(); + + // The same deletion under the default scope withdraws them. + app(Settings::class)->set(Setting::AccountSelfDeleteScope, 'any'); + + $this->actingAs($recipient)->get("/files/{$file->id}/download")->assertForbidden(); +}); + +test('by default a self-delete leaves the files for the grace period', function () { + $owner = User::factory()->client()->create(); + $file = selfDeletionFile($owner); + + selfDeleteAccount($owner); + + expect(File::query()->find($file->id))->not->toBeNull() + ->and(ActivityLog::query()->where('action', Action::AccountContentCascadeDeleted)->exists())->toBeFalse(); +}); + +test('"right away" deletes their own uploads, and their folders only if nothing else is left', function () { + app(Settings::class)->set(Setting::AccountSelfDeleteFiles, 'immediately'); + + $owner = User::factory()->client()->create(); + $file = selfDeletionFile($owner); + $emptyFolder = Folder::query()->create(['name' => 'Mine', 'created_by' => $owner->id]); + $sharedFolder = Folder::query()->create(['name' => 'Ours', 'created_by' => $owner->id]); + $somebodyElses = selfDeletionFile($this->admin, ['folder_id' => $sharedFolder->id]); + $theirs = selfDeletionFile($owner, ['folder_id' => $sharedFolder->id]); + + selfDeleteAccount($owner); + + expect(File::query()->find($file->id))->toBeNull() + ->and(File::query()->find($theirs->id))->toBeNull() + ->and(File::query()->find($somebodyElses->id))->not->toBeNull() + ->and(Folder::query()->find($emptyFolder->id))->toBeNull() + ->and(Folder::query()->find($sharedFolder->id)?->created_by)->toBeNull() + ->and(Folder::query()->find($sharedFolder->id))->not->toBeNull(); + + // The account itself keeps its ordinary grace period. + expect(User::withTrashed()->findOrFail($owner->id)->erase_after)->not->toBeNull() + ->and(ActivityLog::query()->where('action', Action::AccountContentCascadeDeleted)->exists())->toBeTrue(); +}); + +test('"right away" does not reach a staff member when the scope is clients only', function () { + app(Settings::class)->set(Setting::AccountSelfDeleteFiles, 'immediately'); + app(Settings::class)->set(Setting::AccountSelfDeleteScope, 'clients'); + + $staff = User::factory()->create(); + $file = selfDeletionFile($staff); + + selfDeleteAccount($staff); + + expect(File::query()->find($file->id))->not->toBeNull(); +}); + +test('the delete screen says what will happen to the files', function () { + $client = User::factory()->client()->create(); + + $this->actingAs($client)->get('/settings/delete-account')->assertInertia(fn ($page) => $page + ->where('filesWithdrawn', true) + ->where('filesDeletedImmediately', false)); + + app(Settings::class)->set(Setting::AccountSelfDeleteFiles, 'immediately'); + + $this->actingAs($client)->get('/settings/delete-account')->assertInertia(fn ($page) => $page + ->where('filesWithdrawn', true) + ->where('filesDeletedImmediately', true)); + + // Neither happens to staff under the clients-only scope, so neither + // is said to them. + app(Settings::class)->set(Setting::AccountSelfDeleteScope, 'clients'); + + $this->actingAs(User::factory()->create())->get('/settings/delete-account')->assertInertia(fn ($page) => $page + ->where('filesWithdrawn', false) + ->where('filesDeletedImmediately', false)); +}); + +test('a platform can make it "right away", and the settings screen says so instead of offering a switch', function () { + Event::listen(ResolvingSelfDeletion::class, fn (ResolvingSelfDeletion $event) => $event->deleteFilesImmediately()); + + $this->actingAs($this->admin)->get('/system/settings/privacy')->assertInertia(fn ($page) => $page + ->where('account_self_delete_files', 'immediately') + ->where('account_self_delete_files_managed', true)); + + // What comes back from the disabled control is not stored. + $this->actingAs($this->admin)->patch('/system/settings/privacy', [ + 'download_ip_logging' => 'all', + 'account_erasure_grace_days' => 30, + 'account_erasure_content_action' => 'cascade_delete', + 'account_self_delete_files' => 'after_grace_period', + 'account_self_delete_scope' => 'any', + 'api_request_log_retention_days' => 30, + 'discourage_search_indexing' => false, + ])->assertSessionHasNoErrors(); + + $owner = User::factory()->client()->create(); + $file = selfDeletionFile($owner); + selfDeleteAccount($owner); + + expect(File::query()->find($file->id))->toBeNull(); +}); diff --git a/tests/Feature/Platform/PrivacySettingsTest.php b/tests/Feature/Platform/PrivacySettingsTest.php index 72906ae2..eadc39f6 100644 --- a/tests/Feature/Platform/PrivacySettingsTest.php +++ b/tests/Feature/Platform/PrivacySettingsTest.php @@ -35,6 +35,8 @@ test('staff can view and update privacy settings', function () { 'download_ip_logging' => 'anonymous_only', 'account_erasure_grace_days' => 14, 'account_erasure_content_action' => 'cascade_delete', + 'account_self_delete_files' => 'immediately', + 'account_self_delete_scope' => 'clients', 'api_request_log_retention_days' => 7, 'discourage_search_indexing' => true, ])->assertRedirect(); @@ -42,6 +44,8 @@ test('staff can view and update privacy settings', function () { $settings = app(Settings::class); expect($settings->get(Setting::DownloadIpLogging))->toBe('anonymous_only') ->and($settings->get(Setting::AccountErasureGraceDays))->toBe(14) + ->and($settings->get(Setting::AccountSelfDeleteFiles))->toBe('immediately') + ->and($settings->get(Setting::AccountSelfDeleteScope))->toBe('clients') ->and($settings->get(Setting::ApiRequestLogRetentionDays))->toBe(7) ->and($settings->get(Setting::DiscourageSearchIndexing))->toBeTrue();