Files
ignacionelson bccf3d1f29 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.
2026-09-24 16:58:48 -03:00

120 lines
5.4 KiB
TypeScript

import { useForm } from '@inertiajs/react';
import { FormEventHandler, useRef } from 'react';
// Components...
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import HeadingSmall from '@/components/heading-small';
import { useTranslation } from '@/hooks/use-translation';
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
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<HTMLInputElement>(null);
const { data, setData, delete: destroy, processing, reset, errors, clearErrors } = useForm({ password: '' });
const deleteUser: FormEventHandler = (e) => {
e.preventDefault();
destroy(route('profile.destroy'), {
preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.current?.focus(),
onFinish: () => reset(),
});
};
const closeModal = () => {
clearErrors();
reset();
};
return (
<div className="space-y-6">
<HeadingSmall
title={t('Delete account')}
description={t('Deactivate your account now; it is permanently erased after :days days', { days: graceDays })}
/>
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
<p className="font-medium">{t('Warning')}</p>
<p className="text-sm">
{t(
'Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.',
{ days: graceDays },
)}
</p>
{filesNotice !== null && <p className="text-sm font-medium">{filesNotice}</p>}
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">{t('Delete account')}</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>{t('Are you sure you want to delete your account?')}</DialogTitle>
<DialogDescription>
{t('Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.', {
days: graceDays,
})}
</DialogDescription>
{filesDeletedImmediately && <p className="text-destructive text-sm font-medium">{filesNotice}</p>}
<form className="space-y-6" onSubmit={deleteUser}>
<div className="grid gap-2">
<Label htmlFor="password" className="sr-only">
{t('Password')}
</Label>
<Input
id="password"
type="password"
name="password"
ref={passwordInput}
value={data.password}
onChange={(e) => setData('password', e.target.value)}
placeholder={t('Password')}
autoComplete="current-password"
/>
<InputError message={errors.password} />
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary" onClick={closeModal}>
{t('Cancel')}
</Button>
</DialogClose>
<Button variant="destructive" disabled={processing} asChild>
<button type="submit">{t('Delete account')}</button>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
);
}