From c21658f6f7dc29ec2e588564b7c870ea30194c73 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Sun, 13 Sep 2026 14:57:16 -0300 Subject: [PATCH] Let a client account expire on a date Staff can give a client an expiry date on the create and edit screens, and through /api/v1/clients. When the date passes, the client is refused at sign-in and on their next request, and their API access ends too. Files and history stay, and a later date (or none) brings them back. Access is checked through one predicate, User::maySignIn(), at every door: sign-in, the web session, API tokens and the two-factor challenge. An hourly sweep also switches `active` off, so the list, its filter and seat counts agree. The sweep is not what enforces it, so a scheduler that is not running cannot keep an account open. An account cannot be active with a date that has passed. Reactivating an expired client needs a new date in the same save. The day-means-end-of-day-where-you-are rule moved out of FileExpiry into a shared DateInput, so file and account expiry read dates the same way. Requested by @Drardollan in #1310. --- app/Models/User.php | 31 ++ .../Middleware/EnsureApiAccountIsActive.php | 6 +- app/Modules/Audit/Action.php | 6 + app/Modules/Clients/ClientAccounts.php | 30 +- .../Clients/ClientsServiceProvider.php | 6 + .../Console/ExpireClientAccountsCommand.php | 64 ++++ .../Controllers/Api/ClientsController.php | 39 ++- .../Http/Controllers/ClientsController.php | 41 ++- .../Http/Resources/Api/ClientResource.php | 4 + app/Modules/Files/Editing/FileExpiry.php | 41 +-- app/Modules/Identity/AccountConversion.php | 4 + .../TwoFactorChallengeController.php | 2 +- .../Http/Middleware/EnsureAccountIsActive.php | 10 +- app/Modules/Identity/SignIn.php | 13 +- .../SchedulerMonitoringController.php | 1 + .../Platform/Localization/DateInput.php | 63 ++++ ...3_090000_add_expires_at_to_users_table.php | 29 ++ docs/api/openapi.json | 25 ++ .../js/components/client-expiry-field.tsx | 45 +++ resources/js/pages/clients/create.tsx | 5 + resources/js/pages/clients/edit.tsx | 14 + resources/js/pages/clients/index.tsx | 19 +- routes/console.php | 4 + tests/Feature/Clients/ClientExpiryTest.php | 308 ++++++++++++++++++ .../Platform/SchedulerMonitoringTest.php | 2 +- 25 files changed, 761 insertions(+), 51 deletions(-) create mode 100644 app/Modules/Clients/Console/ExpireClientAccountsCommand.php create mode 100644 app/Modules/Platform/Localization/DateInput.php create mode 100644 database/migrations/2026_09_13_090000_add_expires_at_to_users_table.php create mode 100644 resources/js/components/client-expiry-field.tsx create mode 100644 tests/Feature/Clients/ClientExpiryTest.php 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 ( +
+ + onChange(e.target.value)} /> + {expired && value !== '' ? ( +

+ {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.')} +

+ )} + +
+ ); +} diff --git a/resources/js/pages/clients/create.tsx b/resources/js/pages/clients/create.tsx index 1fff8d7e..bdfd611d 100644 --- a/resources/js/pages/clients/create.tsx +++ b/resources/js/pages/clients/create.tsx @@ -3,6 +3,7 @@ import { Head, useForm } from '@inertiajs/react'; import { FormEventHandler } from 'react'; import { ClientCustomFieldsSection, type CustomFieldDefinition } from '@/components/client-custom-fields-section'; +import { ClientExpiryField } from '@/components/client-expiry-field'; import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; @@ -19,6 +20,7 @@ interface ClientFormData { password: string; password_confirmation: string; storage_quota_mb: string; + expires_at: string; custom_field_values: Record; } @@ -43,6 +45,7 @@ export default function ClientsCreate({ custom_fields, default_storage_quota_mb // Empty = inherit the site default rather than baking in today's // numeric value — see the field's own hint text below. storage_quota_mb: '', + expires_at: '', custom_field_values: Object.fromEntries(custom_fields.map((field) => [field.id, field.type === 'checkbox' ? '0' : ''])), }); @@ -125,6 +128,8 @@ export default function ClientsCreate({ custom_fields, default_storage_quota_mb + setData('expires_at', value)} error={errors.expires_at} /> + ; } @@ -73,6 +77,9 @@ export default function ClientsEdit({ // input shows the resolved default as a placeholder instead of a // number the admin has to know to type themselves. storage_quota_mb: client.storage_quota_mb > 0 ? String(client.storage_quota_mb) : '', + // Posted back exactly as received when untouched — the server + // compares against this string to tell a new date from an old one. + expires_at: client.expires_at ?? '', custom_field_values: Object.fromEntries( custom_fields.map((field) => [field.id, custom_field_values[field.id] ?? (field.type === 'checkbox' ? '0' : '')]), ), @@ -155,6 +162,13 @@ export default function ClientsEdit({ )} + setData('expires_at', value)} + error={errors.expires_at} + expired={client.expired && data.expires_at === (client.expires_at ?? '')} + /> +
().props; const can = (permission: string) => auth.permissions.includes(permission); @@ -58,7 +62,20 @@ export default function ClientsIndex({ clients, pagination, filters, reassign_ca return {t('Pending approval')}; } - return {client.active ? t('Active') : t('Inactive')}; + // Ahead of the flag: the sweep that switches an expired account off + // runs hourly, and in between the account already refuses sign-ins. + if (client.expired) { + return {t('Expired')}; + } + + return ( +
+ {client.active ? t('Active') : t('Inactive')} + {client.active && client.expires_on && ( + {t('Until :date', { date: calendarDate(client.expires_on) })} + )} +
+ ); }; return ( diff --git a/routes/console.php b/routes/console.php index 709b596e..8a83a213 100644 --- a/routes/console.php +++ b/routes/console.php @@ -14,6 +14,10 @@ Schedule::command('projectsend:purge-erasures')->daily(); // and the sweep noticing is a gap where somebody cannot upload. Daily made // that gap up to two days wide. Schedule::command('projectsend:purge-stale-uploads')->hourly(); +// Hourly for the same reason: an expired client still marked active holds +// a seat on a managed plan. Access itself does not wait for this — see +// User::maySignIn(). +Schedule::command('projectsend:expire-client-accounts')->hourly(); Schedule::command('projectsend:purge-zip-downloads')->daily(); Schedule::command('projectsend:check-for-updates')->daily(); Schedule::command('projectsend:fetch-news')->daily(); diff --git a/tests/Feature/Clients/ClientExpiryTest.php b/tests/Feature/Clients/ClientExpiryTest.php new file mode 100644 index 00000000..c170474d --- /dev/null +++ b/tests/Feature/Clients/ClientExpiryTest.php @@ -0,0 +1,308 @@ +admin = User::factory()->create(); +}); + +/* +|-------------------------------------------------------------------------- +| Access ends when the date passes, without waiting for the sweep +|-------------------------------------------------------------------------- +*/ + +test('a client whose date has passed cannot sign in, even while still marked active', function () { + $client = User::factory()->client()->create(['email' => 'late@example.com']); + $client->forceFill(['expires_at' => now()->subMinute()])->save(); + + expect($client->refresh()->active)->toBeTrue(); + + $this->post('/login', ['email' => 'late@example.com', 'password' => 'password']) + ->assertSessionHasErrors(['email' => 'Your account has expired.']); + + $this->assertGuest(); +}); + +test('a client whose date is still ahead signs in normally', function () { + $client = User::factory()->client()->create(['email' => 'early@example.com']); + $client->forceFill(['expires_at' => now()->addDay()])->save(); + + $this->post('/login', ['email' => 'early@example.com', 'password' => 'password']) + ->assertSessionHasNoErrors(); + + $this->assertAuthenticatedAs($client); +}); + +test('an open session ends on the first request after the date passes', function () { + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->addHour()])->save(); + + $this->actingAs($client)->get('/my-files')->assertOk(); + + $this->travel(2)->hours(); + + $this->actingAs($client)->get('/my-files') + ->assertRedirect(route('login')) + ->assertSessionHasErrors(['email' => 'Your account has expired.']); +}); + +test('an expired account\'s API token stops working', function () { + // Only clients are given a date, and tokens are staff-only — so this + // is the gate proven on the one account type that can hold a token. + $staff = User::factory()->create(); + $token = $staff->createToken('t', [Permission::ManageClients->value])->plainTextToken; + $staff->forceFill(['expires_at' => now()->subMinute()])->save(); + + $this->withToken($token)->getJson('/api/v1/clients')->assertUnauthorized(); +}); + +/* +|-------------------------------------------------------------------------- +| The hourly sweep +|-------------------------------------------------------------------------- +*/ + +test('the sweep deactivates expired clients and nobody else', function () { + $expired = User::factory()->client()->create(['name' => 'Gone']); + $expired->forceFill(['expires_at' => now()->subMinute()])->save(); + + $future = User::factory()->client()->create(); + $future->forceFill(['expires_at' => now()->addDay()])->save(); + + $never = User::factory()->client()->create(); + + // Only client accounts are swept; a staff row carrying a stray date is + // refused at the door by maySignIn(), but not switched off here. + $staff = User::factory()->create(); + $staff->forceFill(['expires_at' => now()->subMinute()])->save(); + + $this->artisan('projectsend:expire-client-accounts')->assertSuccessful(); + + expect($expired->refresh()->active)->toBeFalse() + ->and($future->refresh()->active)->toBeTrue() + ->and($never->refresh()->active)->toBeTrue() + ->and($staff->refresh()->active)->toBeTrue(); + + $entries = ActivityLog::query()->where('action', Action::ClientExpired)->get(); + expect($entries)->toHaveCount(1) + ->and($entries->first()->context)->toBe(['name' => 'Gone', 'id' => $expired->id]); +}); + +test('the sweep does not log a client that is already inactive', function () { + $client = User::factory()->client()->create(['active' => false]); + $client->forceFill(['expires_at' => now()->subDay()])->save(); + + $this->artisan('projectsend:expire-client-accounts')->assertSuccessful(); + + expect(ActivityLog::query()->where('action', Action::ClientExpired)->count())->toBe(0); +}); + +/* +|-------------------------------------------------------------------------- +| Staff screens +|-------------------------------------------------------------------------- +*/ + +test('a date set on the create screen means the end of that day where the creator is', function () { + $this->admin->forceFill(['timezone' => 'America/Argentina/Buenos_Aires'])->save(); + + $this->actingAs($this->admin)->post('/clients', [ + 'name' => 'Seasonal', + 'email' => 'seasonal@example.com', + 'password' => 'super-secret-password', + 'password_confirmation' => 'super-secret-password', + 'expires_at' => '2030-01-10', + ])->assertSessionHasNoErrors(); + + $client = User::query()->where('email', 'seasonal@example.com')->sole(); + + // 23:59:59 in Buenos Aires (UTC-3) is 02:59:59 the next morning in UTC. + expect($client->expires_at?->utc()->toDateTimeString())->toBe('2030-01-11 02:59:59'); +}); + +test('a client cannot be created already expired', function () { + $this->actingAs($this->admin)->post('/clients', [ + 'name' => 'Too Late', + 'email' => 'too-late@example.com', + 'password' => 'super-secret-password', + 'password_confirmation' => 'super-secret-password', + 'expires_at' => now()->subDays(2)->toDateString(), + ])->assertSessionHasErrors('expires_at'); + + expect(User::query()->where('email', 'too-late@example.com')->exists())->toBeFalse(); +}); + +test('saving the edit screen without touching the date leaves the stored instant alone', function () { + // Set by somebody in Tokyo, then re-saved by somebody in Buenos Aires + // who only changed the name. Re-deriving the posted day would move the + // expiry by twelve hours. + $client = User::factory()->client()->create(); + $stored = Carbon::parse('2030-06-15 14:59:59', 'UTC'); + $client->forceFill(['expires_at' => $stored])->save(); + + $this->admin->forceFill(['timezone' => 'America/Argentina/Buenos_Aires'])->save(); + + $shown = null; + $this->actingAs($this->admin)->get("/clients/{$client->id}")->assertInertia( + function (AssertableInertia $page) use (&$shown) { + $shown = $page->toArray()['props']['client']['expires_at']; + }, + ); + + $this->actingAs($this->admin)->patch("/clients/{$client->id}", [ + 'name' => 'Renamed', + 'email' => $client->email, + 'active' => true, + 'expires_at' => $shown, + ])->assertSessionHasNoErrors(); + + expect($client->refresh()->expires_at?->utc()->toDateTimeString())->toBe('2030-06-15 14:59:59') + ->and($client->name)->toBe('Renamed'); +}); + +test('an expired client cannot be switched back on without a new date', function () { + $client = User::factory()->client()->create(['active' => false]); + $client->forceFill(['expires_at' => now()->subDays(3)])->save(); + + $shown = now()->subDays(3)->toDateString(); + + $this->actingAs($this->admin)->patch("/clients/{$client->id}", [ + 'name' => $client->name, + 'email' => $client->email, + 'active' => true, + 'expires_at' => $shown, + ])->assertSessionHasErrors('expires_at'); + + expect($client->refresh()->active)->toBeFalse(); + + $this->actingAs($this->admin)->patch("/clients/{$client->id}", [ + 'name' => $client->name, + 'email' => $client->email, + 'active' => true, + 'expires_at' => now()->addMonth()->toDateString(), + ])->assertSessionHasNoErrors(); + + expect($client->refresh()->active)->toBeTrue() + ->and($client->hasExpired())->toBeFalse(); +}); + +test('clearing the date removes the expiry', function () { + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->addWeek()])->save(); + + $this->actingAs($this->admin)->patch("/clients/{$client->id}", [ + 'name' => $client->name, + 'email' => $client->email, + 'active' => true, + 'expires_at' => '', + ])->assertSessionHasNoErrors(); + + expect($client->refresh()->expires_at)->toBeNull(); +}); + +test('the list calls an expired client expired before the sweep has run', function () { + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->subMinute()])->save(); + + $this->actingAs($this->admin)->get('/clients')->assertInertia( + fn (AssertableInertia $page) => $page + ->where('clients.0.expired', true) + ->where('clients.0.active', true), + ); +}); + +test('converting a client to staff drops the expiry', function () { + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->addWeek()])->save(); + + $roleId = (int) \App\Modules\Identity\Models\Role::query()->where('name', SystemRole::AccountManager->value)->value('id'); + + app(AccountConversion::class)->toStaff($client, $roleId, [], 'a-brand-new-password-123'); + + expect($client->refresh()->expires_at)->toBeNull(); +}); + +/* +|-------------------------------------------------------------------------- +| API +|-------------------------------------------------------------------------- +*/ + +function expiryToken(User $admin): string +{ + return $admin->createToken('t', [ + Permission::ManageClients->value, + Permission::CreateClients->value, + Permission::EditClients->value, + ])->plainTextToken; +} + +test('the API creates, shows and clears an expiry', function () { + $token = expiryToken($this->admin); + + $id = $this->withToken($token)->postJson('/api/v1/clients', [ + 'name' => 'Api Client', + 'email' => 'api-expiry@example.com', + 'password' => 'super-secret-password', + 'expires_at' => '2030-03-01T12:00:00Z', + ])->assertCreated() + ->assertJsonPath('data.expires_at', '2030-03-01T12:00:00+00:00') + ->json('data.id'); + + $this->withToken($token)->patchJson("/api/v1/clients/{$id}", ['expires_at' => null]) + ->assertOk() + ->assertJsonPath('data.expires_at', null); +}); + +test('the API refuses to reactivate an expired client without a new date', function () { + $token = expiryToken($this->admin); + $client = User::factory()->client()->create(['active' => false]); + $client->forceFill(['expires_at' => now()->subDay()])->save(); + + $this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['active' => true]) + ->assertUnprocessable() + ->assertJsonValidationErrors('expires_at'); + + $this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['active' => true, 'expires_at' => null]) + ->assertOk() + ->assertJsonPath('data.active', true); +}); + +test('an API rename is not refused over a date it did not send', function () { + $token = expiryToken($this->admin); + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->subMinute()])->save(); + + $this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['name' => 'Just A Rename']) + ->assertOk() + ->assertJsonPath('data.name', 'Just A Rename'); +}); + +test('a JSON number where a date belongs is a validation error, not a 500', function () { + // `date` accepts some numbers (20301231 parses as a calendar day) + // and does not convert them, and DateInput::instant() is strictly + // typed to a string. A form post is always text; a JSON body is not. + $token = expiryToken($this->admin); + $client = User::factory()->client()->create(); + + $this->withToken($token)->postJson('/api/v1/clients', [ + 'name' => 'Numeric', + 'email' => 'numeric@example.com', + 'password' => 'super-secret-password', + 'expires_at' => 20301231, + ])->assertUnprocessable()->assertJsonValidationErrors('expires_at'); + + $this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['expires_at' => 20301231]) + ->assertUnprocessable() + ->assertJsonValidationErrors('expires_at'); +}); diff --git a/tests/Feature/Platform/SchedulerMonitoringTest.php b/tests/Feature/Platform/SchedulerMonitoringTest.php index fb80228b..6a14a231 100644 --- a/tests/Feature/Platform/SchedulerMonitoringTest.php +++ b/tests/Feature/Platform/SchedulerMonitoringTest.php @@ -45,7 +45,7 @@ test('the scheduler page lists every known command, flagging ones that have neve ]); $response = $this->actingAs($this->admin)->get('/system/settings/scheduler'); - $response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 11)); + $response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 12)); $tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command'); expect($tasks->get('projectsend:purge-expired-files')['status'])->toBe('success')