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.
This commit is contained in:
ignacionelson
2026-09-13 14:57:16 -03:00
parent 7c7ba7cd53
commit c21658f6f7
25 changed files with 761 additions and 51 deletions
+31
View File
@@ -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',