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',
@@ -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);
}
+6
View File
@@ -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',
+29 -1
View File
@@ -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.'),
]);
}
}
}
@@ -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,
]);
}
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Console;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\UserType;
use Illuminate\Console\Command;
/**
* Switches off client accounts whose expiry date has passed.
*
* Not what stops an expired client getting in User::maySignIn() does
* that the moment the date passes, with or without this. What this does is
* make `active` tell the truth, so everything that reads the flag rather
* than asking the account agrees: the client list and its status filter,
* the API's `active` field, and a managed plan's seat count. Hourly for the
* same reason purge-stale-uploads is: a seat held by an account that can
* no longer use it is a seat somebody else cannot have.
*/
class ExpireClientAccountsCommand extends Command
{
protected $signature = 'projectsend:expire-client-accounts';
protected $description = 'Deactivate client accounts whose expiry date has passed (runs hourly)';
public function handle(ActivityLogger $activity): int
{
$now = now();
$expired = 0;
$due = User::query()
->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;
}
}
@@ -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.
@@ -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
@@ -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(),
];
+10 -31
View File
@@ -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);
}
}
@@ -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) {
@@ -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;
}
}
@@ -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.'),
]));
}
+10 -3
View File
@@ -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.');
}
@@ -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'),
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Localization;
use App\Models\User;
use Carbon\Carbon;
/**
* A stored instant, read and written as a calendar day in the zone of
* whoever is looking.
*
* Every expiry in the application is stored as an instant, and every
* person sets one with a date input. "The 12th" means the end of the 12th
* where *they* live otherwise something 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 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 changed
* something else and the date came along for the ride". Re-deriving on
* every save instead moves the instant by the difference between two
* people's zones each time somebody edits anything.
*
* Shared by a file's expiry (FileExpiry) and a client account's.
*/
class DateInput
{
public function __construct(
private readonly TimezoneRegistry $timezones,
) {}
/**
* The stored instant as the calendar day a form should show, in the
* viewer's zone. Null when there is no instant.
*/
public function asShown(?Carbon $instant, ?User $viewer): ?string
{
return $instant?->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);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// The instant a client account stops working. Null means it
// never does. See User::hasExpired() for how it is enforced,
// and ExpireClientAccountsCommand for why `active` is also
// switched off once it passes.
$table->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');
});
}
};
+25
View File
@@ -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",
@@ -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 (
<div className="grid gap-2">
<Label htmlFor="expires_at">{t('Account expires')}</Label>
<Input id="expires_at" type="date" className="w-48" value={value} onChange={(e) => onChange(e.target.value)} />
{expired && value !== '' ? (
<p className="text-destructive text-xs">
{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),
})}
</p>
) : (
<p className="text-muted-foreground text-xs">
{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.')}
</p>
)}
<InputError message={error} />
</div>
);
}
+5
View File
@@ -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<string, string>;
}
@@ -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
<InputError message={errors.storage_quota_mb} />
</div>
<ClientExpiryField value={data.expires_at} onChange={(value) => setData('expires_at', value)} error={errors.expires_at} />
<ClientCustomFieldsSection
fields={custom_fields}
values={data.custom_field_values}
+14
View File
@@ -4,6 +4,7 @@ import { FormEventHandler } from 'react';
import { AccountContentDeleteDialog, type ReassignCandidate } from '@/components/account-content-delete-dialog';
import { ClientCustomFieldsSection, type CustomFieldDefinition } from '@/components/client-custom-fields-section';
import { ClientExpiryField } from '@/components/client-expiry-field';
import { ConfirmDialog } from '@/components/confirm-dialog';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
@@ -26,6 +27,8 @@ interface ClientsEditProps {
account_requested: boolean;
storage_quota_mb: number;
two_factor_enabled: boolean;
expires_at: string | null;
expired: boolean;
};
default_storage_quota_mb: number;
storage_used_mb: number;
@@ -43,6 +46,7 @@ interface ClientFormData {
password: string;
password_confirmation: string;
storage_quota_mb: string;
expires_at: string;
custom_field_values: Record<string, string>;
}
@@ -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({
)}
<InputError message={errors.active} />
<ClientExpiryField
value={data.expires_at}
onChange={(value) => setData('expires_at', value)}
error={errors.expires_at}
expired={client.expired && data.expires_at === (client.expires_at ?? '')}
/>
<div className="grid gap-2">
<Label htmlFor="storage_quota_mb">{t('Storage quota (MB)')}</Label>
<Input
+18 -1
View File
@@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useFormatDate } from '@/hooks/use-format-date';
import { ALL, useListQuery } from '@/hooks/use-list-query';
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
@@ -22,6 +23,8 @@ interface ClientRow {
email: string;
active: boolean;
account_requested: boolean;
expires_on: string | null;
expired: boolean;
created_at: string | null;
content: { files: number; folders: number };
}
@@ -41,6 +44,7 @@ interface ClientsIndexProps {
export default function ClientsIndex({ clients, pagination, filters, reassign_candidates, seats }: ClientsIndexProps) {
const { t } = useTranslation();
const { calendarDate } = useFormatDate();
const { auth } = usePage<SharedData>().props;
const can = (permission: string) => auth.permissions.includes(permission);
@@ -58,7 +62,20 @@ export default function ClientsIndex({ clients, pagination, filters, reassign_ca
return <Badge variant="outline">{t('Pending approval')}</Badge>;
}
return <Badge variant={client.active ? 'secondary' : 'destructive'}>{client.active ? t('Active') : t('Inactive')}</Badge>;
// 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 <Badge variant="destructive">{t('Expired')}</Badge>;
}
return (
<div className="flex flex-wrap items-center gap-2">
<Badge variant={client.active ? 'secondary' : 'destructive'}>{client.active ? t('Active') : t('Inactive')}</Badge>
{client.active && client.expires_on && (
<span className="text-muted-foreground text-xs">{t('Until :date', { date: calendarDate(client.expires_on) })}</span>
)}
</div>
);
};
return (
+4
View File
@@ -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();
+308
View File
@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Identity\AccountConversion;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->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');
});
@@ -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')