mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
f06a3c7ab3
Invitations produced no in-app notification at all, and neither did self-registration: the whole Clients module raised none. The only admin-facing signal when an account appeared was an email to whatever raw addresses an operator typed into a setting -- addresses that need not correspond to any account in this installation, and that plenty of installations never fill in. An invitation could be accepted and nobody signed in would ever be told. So: one new type, client_registered, reaching the bell and /notifications. One type for both doors on purpose. A client arriving through the public form and one arriving through an invitation are the same event to the person being told -- an account now exists that did not -- and a second type would buy nothing, because preferences here govern email only, so it could not have been switched off separately anyway. Which door it came through is one click away in the activity log and on the invitations screen. In-app only, the reasoning client_uploaded already states: email for this event is sent separately to that address list, and routing it through Notifier's mail dispatch too would risk double-emailing any staff member who is also on it. Two things worth stating about who gets it. Recipients are resolved at the call site, because Notifier authorizes nothing by design -- its security contract is explicit that a broad query must never be handed to it. And a client-scoped staff member is deliberately not told: their whole view is the clients assigned to them, and a brand-new account is assigned to nobody, so it would link them to a screen they are refused. Which is also why the notification links to the clients list filtered to the address, and not to clients.edit: that route is gated by edit_clients while these recipients are chosen by manage_clients. A notification that refuses the person it was sent to is worse than one that lands a click short. Translated in all sixteen locales, and the redemption was driven through a real browser to see the row arrive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
208 lines
8.1 KiB
PHP
208 lines
8.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\AdminClientRegisteredNotification;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Identity\AuthSource;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Permissions\SystemRole;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use App\Modules\Identity\Permissions\PermissionChecker;
|
|
use App\Modules\Identity\UserType;
|
|
use App\Modules\Notifications\Notifier;
|
|
use App\Modules\Platform\Seats\SeatAllowance;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\Notification;
|
|
|
|
/**
|
|
* A client account coming into existence without a staff member creating
|
|
* it by hand.
|
|
*
|
|
* Two entry points now reach this — the public registration form and a
|
|
* first successful LDAP sign-in — and they must agree on the parts that
|
|
* are policy rather than presentation: whether the account is active or
|
|
* waits for approval, which group it joins, and who gets told. Keeping one
|
|
* definition is the same reasoning FileSharing and StoreUploadedFile
|
|
* already follow.
|
|
*
|
|
* What stays with each caller is what genuinely differs: the registration
|
|
* form's custom fields and group requests, and LDAP's directory stamp.
|
|
*/
|
|
class ClientProvisioning
|
|
{
|
|
public function __construct(
|
|
private readonly Settings $settings,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly SeatAllowance $seats,
|
|
private readonly Notifier $notifier,
|
|
private readonly PermissionChecker $permissions,
|
|
) {}
|
|
|
|
/**
|
|
* Whether a newly provisioned client can sign in straight away.
|
|
*/
|
|
public function autoApproves(): bool
|
|
{
|
|
return $this->settings->get(Setting::ClientsAutoApprove) === true;
|
|
}
|
|
|
|
/**
|
|
* Whether an address is free for a new account.
|
|
*
|
|
* The unique index on `email` spans soft-deleted rows — AvailableEmailRule
|
|
* is built on exactly that, so a deleted account keeps its address until
|
|
* erasure takes the row away. The registration form learns this from
|
|
* validation. The machine paths have no form to validate: a directory or
|
|
* an identity provider hands over an address and provision() inserts it,
|
|
* so without asking first the insert raises a QueryException in the
|
|
* middle of somebody's sign-in.
|
|
*/
|
|
public function addressIsFree(string $email): bool
|
|
{
|
|
return ! User::withTrashed()->where('email', $email)->exists();
|
|
}
|
|
|
|
/**
|
|
* @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove,
|
|
* which is the right question for the
|
|
* public registration form. A caller
|
|
* that has already established who
|
|
* somebody is — LDAP, an identity
|
|
* provider — passes its own answer
|
|
* instead.
|
|
* @param array<string, mixed> $context Placeholders for the action's
|
|
* log template, e.g. which
|
|
* provider an account came from.
|
|
* @param int $storageQuotaMb 0 means no per-account quota and
|
|
* inherits the site default at
|
|
* enforcement time — see
|
|
* ClientStorageUsage::quotaMb(). Same
|
|
* meaning as ClientAccounts::create()'s
|
|
* parameter of the same name; a caller
|
|
* with no quota to offer (the public
|
|
* registration form, LDAP) leaves it at 0.
|
|
*/
|
|
public function provision(
|
|
string $name,
|
|
string $email,
|
|
string $password,
|
|
Action $action,
|
|
AuthSource $source = AuthSource::Local,
|
|
?string $ldapDn = null,
|
|
?bool $autoApprove = null,
|
|
array $context = [],
|
|
int $storageQuotaMb = 0,
|
|
): User {
|
|
$autoApprove ??= $this->autoApproves();
|
|
|
|
// Only when the account arrives already approved. A request that
|
|
// still needs a decision is not yet a client this installation has
|
|
// taken on, and counting one would let a stranger exhaust a paid
|
|
// limit from the registration form — see SeatAllowance. The guard
|
|
// for those sits on approval instead.
|
|
if ($autoApprove) {
|
|
$this->seats->guardClient();
|
|
}
|
|
|
|
$client = User::create([
|
|
'type' => UserType::Client,
|
|
'active' => $autoApprove,
|
|
'account_requested' => ! $autoApprove,
|
|
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
|
'name' => $name,
|
|
'email' => $email,
|
|
'password' => $password,
|
|
'storage_quota_mb' => $storageQuotaMb,
|
|
]);
|
|
|
|
// Not mass-assignable: where an account's credentials live is a
|
|
// security decision, not an attribute a form may set.
|
|
if ($source !== AuthSource::Local || $ldapDn !== null) {
|
|
$client->forceFill([
|
|
'auth_source' => $source,
|
|
'ldap_dn' => $ldapDn,
|
|
'ldap_synced_at' => $ldapDn === null ? null : now(),
|
|
])->save();
|
|
}
|
|
|
|
$this->activity->log($action, $client, $client, $context);
|
|
|
|
$this->joinAutoGroup($client);
|
|
$this->notifyAdministrators($client, pending: ! $autoApprove);
|
|
$this->notifyStaffInApp($client);
|
|
|
|
return $client;
|
|
}
|
|
|
|
/**
|
|
* The group every self-provisioned client joins, if one is configured.
|
|
* Direct membership, no approval — an administrator chose this in
|
|
* settings.
|
|
*/
|
|
private function joinAutoGroup(User $client): void
|
|
{
|
|
$autoGroupId = (int) $this->settings->get(Setting::ClientsAutoGroup);
|
|
|
|
if ($autoGroupId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$group = Group::query()->find($autoGroupId);
|
|
|
|
$group?->members()->syncWithoutDetaching([$client->id]);
|
|
}
|
|
|
|
/**
|
|
* The bell, for staff who administer clients.
|
|
*
|
|
* Separate from notifyAdministrators() above, and not a replacement for
|
|
* it: that one emails a list of raw addresses an operator typed into a
|
|
* setting, which need not correspond to any account in this
|
|
* installation. This one reaches the people actually signed in, which
|
|
* is the only place an account arriving unannounced was ever going to
|
|
* be noticed.
|
|
*
|
|
* Recipients are resolved here rather than inside Notifier, which
|
|
* authorizes nothing by design — see its security contract. Two rules,
|
|
* and the second is the one worth stating: a client-scoped staff member
|
|
* is not told. Their whole view is the clients assigned to them, and a
|
|
* brand-new account is assigned to nobody, so the notification would
|
|
* link them to a screen they are refused.
|
|
*/
|
|
private function notifyStaffInApp(User $client): void
|
|
{
|
|
$recipients = User::query()
|
|
->where('type', UserType::Staff)
|
|
->get()
|
|
->filter(fn (User $staff): bool => ! $staff->isClientScoped()
|
|
&& $this->permissions->allows($staff, Permission::ManageClients));
|
|
|
|
$this->notifier->send('client_registered', $recipients, subject: $client, data: [
|
|
'clientName' => $client->name,
|
|
'clientEmail' => $client->email,
|
|
]);
|
|
}
|
|
|
|
private function notifyAdministrators(User $client, bool $pending): void
|
|
{
|
|
if ($this->settings->get(Setting::EmailNotificationsEnabled) !== true) {
|
|
return;
|
|
}
|
|
|
|
$addresses = $this->settings->get(Setting::AdminNotificationEmails);
|
|
|
|
foreach (is_array($addresses) ? $addresses : [] as $address) {
|
|
Notification::route('mail', $address)->notify(
|
|
new AdminClientRegisteredNotification($client->name, $client->email, $pending)
|
|
);
|
|
}
|
|
}
|
|
}
|