diff --git a/app/Models/User.php b/app/Models/User.php index 4b779231..5b475699 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -32,6 +32,7 @@ use Laravel\Sanctum\HasApiTokens; * @property int|null $dashboard_columns * @property int $storage_quota_mb * @property Carbon|null $erase_after + * @property \Carbon\Carbon|null $expires_at * @property-read Role|null $role */ class User extends Authenticatable implements HasLocalePreference @@ -96,6 +97,32 @@ class User extends Authenticatable implements HasLocalePreference return $this->isStaff() && $this->role?->client_scoped === true; } + /** + * Whether this account's expiry date has passed. Only client accounts + * are given one (see the client screens and /api/v1/clients). + */ + public function hasExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + /** + * The one question every door into the application asks of an account + * that has already proved who it is: sign-in, every web request, every + * API request, and the second-factor challenge. + * + * Expiry is checked here as well as by the hourly sweep that switches + * `active` off, and neither is enough alone. The sweep is what keeps + * everything else that reads `active` — lists, filters, seat counts — + * in step. But a sweep runs on a schedule, and a scheduler that is not + * running would leave an expired account working forever. So access + * is refused the moment the date passes, whatever the flag says. + */ + public function maySignIn(): bool + { + return $this->active && ! $this->hasExpired(); + } + public function hasTwoFactorEnabled(): bool { return $this->two_factor_confirmed_at !== null; @@ -183,6 +210,10 @@ class User extends Authenticatable implements HasLocalePreference // is not something those call sites should depend on. 'storage_quota_mb' => 'integer', 'erase_after' => 'datetime', + // Deliberately absent from $fillable too: when an account stops + // working is decided by staff, never by a payload the account + // itself could send (the profile form fills from its request). + 'expires_at' => 'datetime', 'email_verified_at' => 'datetime', 'password' => 'hashed', 'two_factor_secret' => 'encrypted', diff --git a/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php b/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php index b4e2bf01..8e17fa29 100644 --- a/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php +++ b/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php @@ -10,8 +10,8 @@ use Symfony\Component\HttpFoundation\Response; /** * The token twin of Identity's EnsureAccountIsActive: deactivating an - * account revokes its API access on the very next request, without anyone - * having to hunt down the tokens it minted. + * account, or its expiry date passing, revokes its API access on the very + * next request, without anyone having to hunt down the tokens it minted. * * Deleted accounts need no equivalent — users are soft-deleted and the * default query scope means Sanctum simply fails to resolve the tokenable, @@ -26,7 +26,7 @@ class EnsureApiAccountIsActive { $user = $request->user(); - if ($user !== null && ! $user->active) { + if ($user !== null && ! $user->maySignIn()) { abort(401); } diff --git a/app/Modules/Audit/Action.php b/app/Modules/Audit/Action.php index 530dc2ac..64b6190b 100644 --- a/app/Modules/Audit/Action.php +++ b/app/Modules/Audit/Action.php @@ -44,6 +44,10 @@ enum Action: string case ClientInvitationRedeemed = 'client.invitation_redeemed'; case ClientInvitationRevoked = 'client.invitation_revoked'; case ClientInvitationResent = 'client.invitation_resent'; + // Logged by the hourly sweep, so it has no actor: nobody switched the + // account off, its date passed. Distinct from UserDeactivated for the + // same reason TwoFactorReset is distinct from TwoFactorDisabled. + case ClientExpired = 'client.expired'; // Files case FileUploaded = 'file.uploaded'; case FileUpdated = 'file.updated'; @@ -173,6 +177,7 @@ enum Action: string self::ClientInvitationRedeemed => 'Registered a client account from an invitation', self::ClientInvitationRevoked => 'Revoked the invitation sent to :email', self::ClientInvitationResent => 'A new invitation link was requested for :email', + self::ClientExpired => 'The client account ":name" expired and was deactivated', self::FileUploaded => 'Uploaded the file ":subject"', self::FileUpdated => 'Updated the file ":subject"', self::FileDeleted => 'Deleted the file ":name"', @@ -279,6 +284,7 @@ enum Action: string self::ClientInvitationRedeemed => 'A client registered an account from an invitation', self::ClientInvitationRevoked => 'An invitation was revoked before it was used', self::ClientInvitationResent => 'An invited person asked for a replacement link', + self::ClientExpired => 'A client account reached its expiry date and was deactivated', self::FileUploaded => 'A file was uploaded', self::FileUpdated => 'A file was updated', self::FileDeleted => 'A file was deleted', diff --git a/app/Modules/Clients/ClientAccounts.php b/app/Modules/Clients/ClientAccounts.php index 6d327d11..6e859e70 100644 --- a/app/Modules/Clients/ClientAccounts.php +++ b/app/Modules/Clients/ClientAccounts.php @@ -14,6 +14,8 @@ use App\Modules\Identity\UserType; use App\Modules\Platform\Seats\SeatAllowance; use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Settings; +use Carbon\Carbon; +use Illuminate\Validation\ValidationException; /** * Creating a client account — the rules and the side effects, shared by @@ -56,6 +58,9 @@ class ClientAccounts * enforcement time — see * ClientStorageUsage::quotaMb(). It does * not mean unlimited. + * @param Carbon|null $expiresAt when the account stops working; + * null for never. Must be in the + * future — see guardExpiry(). * @param bool $welcome whether this installation should email the * new account. A caller that sends its own * welcome passes false rather than having the @@ -68,6 +73,7 @@ class ClientAccounts int $storageQuotaMb = 0, bool $welcome = true, string $emailField = 'email', + ?Carbon $expiresAt = null, ): User { // Before anything is written, and deliberately not left to the // caller. The platform sets this cap and the platform is also what @@ -76,6 +82,7 @@ class ClientAccounts // that only ran on the surfaces that remembered it would not be a // guard. $this->seats->guardClient($emailField); + $this->guardExpiry($expiresAt, active: true); $client = User::create([ 'type' => UserType::Client, @@ -98,7 +105,10 @@ class ClientAccounts // confirm and nobody to confirm it to. (Inert today, since // MustVerifyEmail is not enabled on the model, but the column is // what a later switch would read.) - $client->forceFill(['email_verified_at' => now()])->save(); + // + // expires_at is written the same way for its own reason: see the + // note on its cast in User. + $client->forceFill(['email_verified_at' => now(), 'expires_at' => $expiresAt])->save(); $this->activity->log(Action::UserCreated, subject: $client); @@ -108,4 +118,22 @@ class ClientAccounts return $client; } + + /** + * An account cannot be both active and past its expiry date. + * + * Every surface that writes either value asks this before saving, + * because the combination is not a state anybody means: an account + * that looks switched on and refuses every sign-in, until the hourly + * sweep quietly switches it off again. Somebody reactivating an + * expired client has to give them a new date, or none. + */ + public function guardExpiry(?Carbon $expiresAt, bool $active, string $field = 'expires_at'): void + { + if ($active && $expiresAt !== null && $expiresAt->isPast()) { + throw ValidationException::withMessages([ + $field => __('This date has already passed. Choose a later date, or leave it empty for an account that never expires.'), + ]); + } + } } diff --git a/app/Modules/Clients/ClientsServiceProvider.php b/app/Modules/Clients/ClientsServiceProvider.php index da6b07b7..8d13b3c8 100644 --- a/app/Modules/Clients/ClientsServiceProvider.php +++ b/app/Modules/Clients/ClientsServiceProvider.php @@ -36,5 +36,11 @@ class ClientsServiceProvider extends ServiceProvider // was sent to is worse than one that lands a click short. url: fn (array $data): string => route('clients.index', ['search' => $data['clientEmail']]), )); + + if ($this->app->runningInConsole()) { + $this->commands([ + Console\ExpireClientAccountsCommand::class, + ]); + } } } diff --git a/app/Modules/Clients/Console/ExpireClientAccountsCommand.php b/app/Modules/Clients/Console/ExpireClientAccountsCommand.php new file mode 100644 index 00000000..b2286af3 --- /dev/null +++ b/app/Modules/Clients/Console/ExpireClientAccountsCommand.php @@ -0,0 +1,64 @@ +where('type', UserType::Client) + ->where('active', true) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', $now) + ->get(['id', 'name', 'expires_at']); + + foreach ($due as $client) { + // Conditional on the row still being due, not a plain save(): + // an administrator who moved the date forward between the read + // above and this write has just decided the account should keep + // working, and must not be overruled by a list that is a few + // milliseconds old. + $switchedOff = User::query() + ->whereKey($client->id) + ->where('active', true) + ->where('expires_at', '<=', $now) + ->update(['active' => false]); + + if ($switchedOff === 1) { + $activity->logSystem(Action::ClientExpired, ['name' => $client->name, 'id' => $client->id]); + $expired++; + } + } + + $this->info("Deactivated {$expired} expired client account(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Clients/Http/Controllers/Api/ClientsController.php b/app/Modules/Clients/Http/Controllers/Api/ClientsController.php index bc9010f1..fcc7bf03 100644 --- a/app/Modules/Clients/Http/Controllers/Api/ClientsController.php +++ b/app/Modules/Clients/Http/Controllers/Api/ClientsController.php @@ -13,6 +13,7 @@ use App\Modules\Clients\ClientAccounts; use App\Modules\Clients\ClientCustomFieldType; use App\Modules\Clients\ClientStorageUsage; use App\Modules\Files\Access\StaffLibraryScope; +use App\Modules\Platform\Localization\DateInput; use App\Modules\Platform\Seats\SeatAllowance; use App\Modules\Clients\Http\Resources\Api\ClientResource; use App\Modules\Clients\Models\ClientCustomField; @@ -62,6 +63,7 @@ class ClientsController extends Controller private readonly SeatAllowance $seats, private readonly ClientAccounts $clients, private readonly ErasureSchedule $erasure, + private readonly DateInput $dates, ) {} public function index(Request $request): AnonymousResourceCollection @@ -130,11 +132,19 @@ class ClientsController extends Controller // installation may be refused on another. 'password' => ['required', Password::defaults()], 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + // When the account stops working; omit or send null for never. + // A bare date (`2026-12-31`) means the end of that day in the + // token owner's timezone; a full timestamp is used as given. + // Must be in the future. + 'expires_at' => ['nullable', 'string', 'date'], 'custom_field_values' => ['array'], ]); $validated['custom_field_values'] = $this->validateCustomFieldValues($request); + $creator = $request->user(); + assert($creator !== null); + // The invariants — the seat guard, the type, the role, the quota's // "0 means inherit" — live in ClientAccounts, shared with the staff // screens and with the platform control plane. What stays here is @@ -151,11 +161,9 @@ class ClientsController extends Controller // this file is strict_types. storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0), welcome: false, + expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator), ); - $creator = $request->user(); - assert($creator !== null); - // A client-scoped creator would otherwise lose the client they just // made. guardTarget() answers 404 for anything off their roster, so // the record they created is not theirs to open, and @@ -191,6 +199,11 @@ class ClientsController extends Controller 'active' => ['sometimes', 'boolean'], 'password' => ['sometimes', 'nullable', Password::defaults()], 'storage_quota_mb' => ['sometimes', 'nullable', 'integer', 'min:0'], + // Send null to remove the expiry. Read the same way as on + // create. An account cannot be active with a date that has + // passed, so reactivating an expired client needs a new date + // (or null) in the same request. + 'expires_at' => ['sometimes', 'nullable', 'string', 'date'], 'custom_field_values' => ['sometimes', 'array'], ]); @@ -209,6 +222,26 @@ class ClientsController extends Controller $client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0; } + // Asked only when this request touches one of the two values, so a + // PATCH renaming a client whose date passed an hour ago is not + // refused over a field it never sent. Resolved with boolean() for + // the reason given in the web controller. + if (array_key_exists('expires_at', $validated) || array_key_exists('active', $validated)) { + $editor = $request->user(); + assert($editor !== null); + + $expiresAt = array_key_exists('expires_at', $validated) + ? $this->dates->instant($validated['expires_at'], $editor) + : $client->expires_at; + + $this->clients->guardExpiry( + $expiresAt, + active: array_key_exists('active', $validated) ? $request->boolean('active') : $client->active, + ); + + $client->expires_at = $expiresAt; + } + // Approval, and so the moment the seat is spent — same rule the // web edit screen and approve() answer to. Inside the branch, so a // capped installation can still edit a client it already holds. diff --git a/app/Modules/Clients/Http/Controllers/ClientsController.php b/app/Modules/Clients/Http/Controllers/ClientsController.php index da1ef5f6..cf9e7b7d 100644 --- a/app/Modules/Clients/Http/Controllers/ClientsController.php +++ b/app/Modules/Clients/Http/Controllers/ClientsController.php @@ -12,6 +12,7 @@ use App\Modules\Clients\ClientAccounts; use App\Modules\Clients\ClientCustomFieldType; use App\Modules\Clients\ClientStorageUsage; use App\Modules\Files\Access\StaffLibraryScope; +use App\Modules\Platform\Localization\DateInput; use App\Modules\Platform\Seats\SeatAllowance; use App\Modules\Clients\Models\ClientCustomField; use App\Modules\Clients\Models\ClientCustomFieldValue; @@ -51,6 +52,7 @@ class ClientsController extends Controller private readonly SeatAllowance $seats, private readonly ClientAccounts $clients, private readonly ErasureSchedule $erasure, + private readonly DateInput $dates, ) {} public function index(Request $request): Response @@ -89,6 +91,12 @@ class ClientsController extends Controller 'email' => $client->email, 'active' => $client->active, 'account_requested' => $client->account_requested, + // A calendar day in the viewer's zone, as the edit form shows + // it, plus whether it has passed: the sweep that switches an + // expired account off runs hourly, and the list should not + // call an account that already refuses sign-ins "Active". + 'expires_on' => $this->dates->asShown($client->expires_at, $viewer), + 'expired' => $client->hasExpired(), 'created_at' => $client->created_at?->toIso8601String(), 'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0], ]); @@ -146,8 +154,12 @@ class ClientsController extends Controller 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule], 'password' => ['required', 'confirmed', Password::defaults()], 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + 'expires_at' => ['nullable', 'string', 'date'], ], $this->customFieldRules())); + $creator = $request->user(); + assert($creator !== null); + // The seat guard, the type, the role, and the quota's "0 means // inherit the site default" all live in ClientAccounts, shared // with the API and the control plane. A client created here is @@ -166,11 +178,9 @@ class ClientsController extends Controller // survived to the fleet. storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0), welcome: false, + expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator), ); - $creator = $request->user(); - assert($creator !== null); - // A client-scoped creator would otherwise lose the client they just // made. guardTarget() answers 404 for anything off their roster, so // the record they created is not theirs to open, and @@ -234,6 +244,10 @@ class ClientsController extends Controller 'account_requested' => $client->account_requested, 'storage_quota_mb' => $client->storage_quota_mb, 'two_factor_enabled' => $client->hasTwoFactorEnabled(), + // update() compares the posted value against this same + // string — see there. + 'expires_at' => $this->dates->asShown($client->expires_at, $request->user()), + 'expired' => $client->hasExpired(), ], // Resolved, not raw — see create() above. 'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(), @@ -260,8 +274,27 @@ class ClientsController extends Controller 'active' => ['required', 'boolean'], 'password' => ['nullable', 'confirmed', Password::defaults()], 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + 'expires_at' => ['nullable', 'string', 'date'], ], $this->customFieldRules())); + $editor = $request->user(); + assert($editor !== null); + + // The form was rendered with the stored instant read back as a day + // in the editor's zone, and posts that string again with every + // other edit. Only a different string is a new date; re-deriving + // an unchanged one would move the expiry by the difference between + // two editors' zones each time either of them renamed the client. + // Same rule as a file's expiry (FilesController::update). + $postedExpiry = $validated['expires_at'] ?? null; + $expiresAt = $postedExpiry !== $this->dates->asShown($client->expires_at, $editor) + ? $this->dates->instant($postedExpiry, $editor) + : $client->expires_at; + + // Request::boolean(), not the validated value: `boolean` accepts + // "1" and "0" without converting them. + $this->clients->guardExpiry($expiresAt, active: $request->boolean('active')); + $wasActive = $client->active; $passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== ''; @@ -276,6 +309,8 @@ class ClientsController extends Controller 'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0, ]); + $client->expires_at = $expiresAt; + // Activating a pending account through the edit screen counts as // approval and clears the request flag — which is the moment a // seat is spent, so the cap is asked here for the same reason diff --git a/app/Modules/Clients/Http/Resources/Api/ClientResource.php b/app/Modules/Clients/Http/Resources/Api/ClientResource.php index f8148dcf..286d952c 100644 --- a/app/Modules/Clients/Http/Resources/Api/ClientResource.php +++ b/app/Modules/Clients/Http/Resources/Api/ClientResource.php @@ -79,6 +79,10 @@ class ClientResource extends JsonResource // caller needs to see before removing it. The secret and the // recovery codes stay where they are. 'two_factor_enabled' => $this->hasTwoFactorEnabled(), + // Null when the account never expires. Once this passes the + // client can no longer sign in, and `active` turns false within + // the hour. + 'expires_at' => $this->expires_at?->toIso8601String(), 'created_at' => $this->created_at?->toIso8601String(), 'updated_at' => $this->updated_at?->toIso8601String(), ]; diff --git a/app/Modules/Files/Editing/FileExpiry.php b/app/Modules/Files/Editing/FileExpiry.php index c6ddd850..d6264ddf 100644 --- a/app/Modules/Files/Editing/FileExpiry.php +++ b/app/Modules/Files/Editing/FileExpiry.php @@ -6,35 +6,25 @@ namespace App\Modules\Files\Editing; use App\Models\User; use App\Modules\Files\Models\File; -use App\Modules\Platform\Localization\LocalDay; -use App\Modules\Platform\Localization\TimezoneRegistry; +use App\Modules\Platform\Localization\DateInput; use Carbon\Carbon; /** * Reading and writing a file's expiry in the zone of whoever is looking. * - * The stored value is an instant. What a person sets is a calendar day, - * and "the 12th" means the end of the 12th where *they* live — otherwise a - * file asked to expire on the 12th dies partway through the 11th for - * anyone west of Greenwich, and gives anyone east of it most of a day - * nobody promised. + * The rule itself — a posted day means the end of that day where the + * setter lives, and a form posts back what asShown() gave it — is + * DateInput's, shared with a client account's expiry. This stays as the + * file-shaped door onto it. * - * The two halves have to agree, which is the whole reason they sit - * together: a form is rendered with asShown() and posts the same string - * back untouched with every other edit, so a caller compares against - * asShown() to tell "the editor changed the date" from "the editor renamed - * the file and the date came along for the ride". Re-deriving on every - * save instead moves the expiry by the difference between two people's - * zones each time somebody edits anything. - * - * Was three private copies — the staff editor, the API, and now the client + * Was three private copies — the staff editor, the API, and the client * portal — of which the API's was the only one that could read a * timestamp. */ class FileExpiry { public function __construct( - private readonly TimezoneRegistry $timezones, + private readonly DateInput $dates, ) {} /** @@ -43,25 +33,14 @@ class FileExpiry */ public function asShown(File $file, ?User $viewer): ?string { - return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString(); + return $this->dates->asShown($file->expires_at, $viewer); } /** - * The instant a submitted value actually names. - * - * A bare `YYYY-MM-DD` is a calendar day and means the end of it where - * the setter is — what every date input posts. Anything carrying a - * time is an instant somebody named on purpose and is stored as it - * arrives: the API can express a moment, and a date input cannot. + * The instant a submitted value actually names. See DateInput::instant(). */ public function instant(?string $value, ?User $setter): ?Carbon { - if ($value === null) { - return null; - } - - return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 - ? LocalDay::end($value, $this->timezones->resolve($setter)) - : Carbon::parse($value); + return $this->dates->instant($value, $setter); } } diff --git a/app/Modules/Identity/AccountConversion.php b/app/Modules/Identity/AccountConversion.php index 810b7a9f..6f88395e 100644 --- a/app/Modules/Identity/AccountConversion.php +++ b/app/Modules/Identity/AccountConversion.php @@ -260,6 +260,10 @@ class AccountConversion // `ldap_dn` is deliberately kept: it is the record of where // the account came from, and a demotion makes it live again. 'auth_source' => AuthSource::Local, + // Only client accounts carry an expiry, and no staff screen + // shows one. Kept, it would switch a staff member off on a + // date nobody who manages staff can see or change. + 'expires_at' => null, ]); if ($newPassword !== null) { diff --git a/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php b/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php index 33160330..d7c32e0d 100644 --- a/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php +++ b/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php @@ -90,6 +90,6 @@ class TwoFactorChallengeController extends Controller $user = User::query()->find($id); - return $user instanceof User && $user->active && $user->hasTwoFactorEnabled() ? $user : null; + return $user instanceof User && $user->maySignIn() && $user->hasTwoFactorEnabled() ? $user : null; } } diff --git a/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php b/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php index 517d5dcc..8af00792 100644 --- a/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php +++ b/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php @@ -11,8 +11,8 @@ use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpFoundation\Response; /** - * A deactivated account loses access immediately, not at next login: - * any open session is terminated on the following request. + * A deactivated or expired account loses access immediately, not at next + * login: any open session is terminated on the following request. */ class EnsureAccountIsActive { @@ -20,14 +20,16 @@ class EnsureAccountIsActive { $user = $request->user(); - if ($user !== null && ! $user->active) { + if ($user !== null && ! $user->maySignIn()) { Auth::guard('web')->logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return WriteSafeRedirect::apply($request, redirect()->route('login')->withErrors([ - 'email' => __('Your account has been deactivated.'), + 'email' => $user->hasExpired() + ? __('Your account has expired.') + : __('Your account has been deactivated.'), ])); } diff --git a/app/Modules/Identity/SignIn.php b/app/Modules/Identity/SignIn.php index 0b3ab64d..1d84ce9d 100644 --- a/app/Modules/Identity/SignIn.php +++ b/app/Modules/Identity/SignIn.php @@ -51,12 +51,19 @@ class SignIn */ public function refusalReason(User $user): ?string { - if ($user->active) { + if ($user->maySignIn()) { return null; } - return $user->account_requested - ? __('Your account request has not been approved yet.') + if (! $user->active && $user->account_requested) { + return __('Your account request has not been approved yet.'); + } + + // Ahead of "deactivated", because the hourly sweep also switches + // an expired account off — and "expired" is the reason its owner + // can do something about, by asking for more time. + return $user->hasExpired() + ? __('Your account has expired.') : __('Your account has been deactivated.'); } diff --git a/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php b/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php index d603092a..f5200c11 100644 --- a/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php +++ b/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php @@ -53,6 +53,7 @@ class SchedulerMonitoringController extends Controller return [ 'projectsend:purge-erasures' => (string) __('Purge erased accounts'), 'projectsend:purge-stale-uploads' => (string) __('Purge stale chunked uploads'), + 'projectsend:expire-client-accounts' => (string) __('Deactivate expired client accounts'), 'projectsend:purge-zip-downloads' => (string) __('Purge zip downloads'), 'projectsend:check-for-updates' => (string) __('Check for updates'), 'projectsend:fetch-news' => (string) __('Fetch dashboard news'), diff --git a/app/Modules/Platform/Localization/DateInput.php b/app/Modules/Platform/Localization/DateInput.php new file mode 100644 index 00000000..3cf0a321 --- /dev/null +++ b/app/Modules/Platform/Localization/DateInput.php @@ -0,0 +1,63 @@ +copy()->setTimezone($this->timezones->resolve($viewer))->toDateString(); + } + + /** + * The instant a submitted value actually names. + * + * A bare `YYYY-MM-DD` is a calendar day and means the end of it where + * the setter is — what every date input posts. Anything carrying a + * time is an instant somebody named on purpose and is stored as it + * arrives: the API can express a moment, and a date input cannot. + */ + public function instant(?string $value, ?User $setter): ?Carbon + { + if ($value === null) { + return null; + } + + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 + ? LocalDay::end($value, $this->timezones->resolve($setter)) + : Carbon::parse($value); + } +} diff --git a/database/migrations/2026_09_13_090000_add_expires_at_to_users_table.php b/database/migrations/2026_09_13_090000_add_expires_at_to_users_table.php new file mode 100644 index 00000000..a243054d --- /dev/null +++ b/database/migrations/2026_09_13_090000_add_expires_at_to_users_table.php @@ -0,0 +1,29 @@ +timestamp('expires_at')->nullable()->after('erase_after')->index(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex(['expires_at']); + $table->dropColumn('expires_at'); + }); + } +}; diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 3d44f966..5906f69c 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -808,6 +808,14 @@ ], "minimum": 0 }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the account stops working; omit or send null for never.\nA bare date (`2026-12-31`) means the end of that day in the\ntoken owner's timezone; a full timestamp is used as given.\nMust be in the future." + }, "custom_field_values": { "type": "array", "items": { @@ -945,6 +953,14 @@ ], "minimum": 0 }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Send null to remove the expiry. Read the same way as on\ncreate. An account cannot be active with a date that has\npassed, so reactivating an expired client needs a new date\n(or null) in the same request." + }, "custom_field_values": { "type": "array", "items": { @@ -3534,6 +3550,7 @@ "client.invitation_redeemed", "client.invitation_revoked", "client.invitation_resent", + "client.expired", "file.uploaded", "file.updated", "file.deleted", @@ -3730,6 +3747,13 @@ "type": "boolean", "description": "Whether, not what: the state of the second factor is what a\ncaller needs to see before removing it. The secret and the\nrecovery codes stay where they are." }, + "expires_at": { + "type": [ + "string", + "null" + ], + "description": "Null when the account never expires. Once this passes the\nclient can no longer sign in, and `active` turns false within\nthe hour." + }, "created_at": { "type": [ "string", @@ -3810,6 +3834,7 @@ "active", "account_requested", "two_factor_enabled", + "expires_at", "created_at", "updated_at", "storage", diff --git a/resources/js/components/client-expiry-field.tsx b/resources/js/components/client-expiry-field.tsx new file mode 100644 index 00000000..c001070b --- /dev/null +++ b/resources/js/components/client-expiry-field.tsx @@ -0,0 +1,45 @@ +import InputError from '@/components/input-error'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useFormatDate } from '@/hooks/use-format-date'; +import { useTranslation } from '@/hooks/use-translation'; + +/** + * The date a client account stops working, shared by the create and edit + * screens. The value is a bare `YYYY-MM-DD`: the server reads it as the end + * of that day in the editor's timezone (DateInput::instant()). + */ +export function ClientExpiryField({ + value, + onChange, + error, + expired = false, +}: { + value: string; + onChange: (value: string) => void; + error?: string; + /** The stored date has already passed — edit screen only. */ + expired?: boolean; +}) { + const { t } = useTranslation(); + const { calendarDate } = useFormatDate(); + + return ( +
+ {t('This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.', { + date: calendarDate(value), + })} +
+ ) : ( ++ {t('After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.')} +
+ )} +