mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
c21658f6f7
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.
140 lines
6.1 KiB
PHP
140 lines
6.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Clients;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Permissions\SystemRole;
|
|
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
|
|
* every surface that makes one.
|
|
*
|
|
* The same argument StaffAccounts makes for staff. What a client account
|
|
* *is* — its type, its role, an active flag, a quota where zero means
|
|
* "inherit the site default" rather than "none" — is a set of invariants,
|
|
* and an invariant enforced in one controller and re-implemented in
|
|
* another is one that will eventually hold in only one of them. There are
|
|
* three surfaces onto this now: the staff screens, `/api/v1/clients`, and
|
|
* the platform control plane in the private package, which reaches this
|
|
* by name because it cannot import a host class.
|
|
*
|
|
* What stays with the caller is what genuinely differs: the shape of the
|
|
* request, its validation rules, its response, and anything about *who is
|
|
* asking* — a client-scoped staff member gaining the client on their own
|
|
* roster is a fact about the creator, not about the account created.
|
|
*
|
|
* **Not to be confused with ClientProvisioning**, which sits beside it and
|
|
* handles the other half: an account that comes into existence without
|
|
* anybody deciding to create it — the public registration form, and a
|
|
* first successful LDAP sign-in. The policies genuinely differ rather than
|
|
* merely duplicating. An account made here is approved and verified by
|
|
* construction, because somebody who already knows who this is asked for
|
|
* it; one made there may wait for approval, joins a configured group, and
|
|
* tells the administrators it arrived.
|
|
*/
|
|
class ClientAccounts
|
|
{
|
|
public function __construct(
|
|
private readonly ActivityLogger $activity,
|
|
private readonly SeatAllowance $seats,
|
|
private readonly Settings $settings,
|
|
) {}
|
|
|
|
/**
|
|
* @param int $storageQuotaMb 0 means no per-account quota and
|
|
* inherits the site default at
|
|
* 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
|
|
* customer receive two.
|
|
*/
|
|
public function create(
|
|
string $name,
|
|
string $email,
|
|
string $password,
|
|
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
|
|
// calls the control plane — so enforcing it here is what stops a
|
|
// leaked control token minting accounts without limit. A guard
|
|
// 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,
|
|
'active' => true,
|
|
'account_requested' => false,
|
|
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
|
'name' => $name,
|
|
'email' => $email,
|
|
'password' => $password,
|
|
'storage_quota_mb' => $storageQuotaMb,
|
|
]);
|
|
|
|
// forceFill, and not part of the create() array above: like
|
|
// StaffAccounts, email_verified_at is deliberately absent from
|
|
// User::$fillable — where an account stands is a security decision
|
|
// rather than an attribute — so mass assignment drops it in
|
|
// silence. Every client-creation path used to pass it in that
|
|
// array and lose it. The intent is real: an account created by
|
|
// somebody who already knows who this is has no address to
|
|
// 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.)
|
|
//
|
|
// 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);
|
|
|
|
if ($welcome && $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
|
$client->notify(new ClientWelcomeNotification);
|
|
}
|
|
|
|
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.'),
|
|
]);
|
|
}
|
|
}
|
|
}
|