mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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.'),
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user