Put a new client account in front of the staff who administer clients

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
This commit is contained in:
ignacionelson
2026-09-12 17:34:47 -03:00
parent 123ae68972
commit f06a3c7ab3
20 changed files with 171 additions and 16 deletions
@@ -12,7 +12,10 @@ 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;
@@ -38,6 +41,8 @@ class ClientProvisioning
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly SeatAllowance $seats,
private readonly Notifier $notifier,
private readonly PermissionChecker $permissions,
) {}
/**
@@ -131,6 +136,7 @@ class ClientProvisioning
$this->joinAutoGroup($client);
$this->notifyAdministrators($client, pending: ! $autoApprove);
$this->notifyStaffInApp($client);
return $client;
}
@@ -153,6 +159,37 @@ class ClientProvisioning
$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) {
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
use App\Modules\Notifications\NotificationTypeDefinition;
use App\Modules\Notifications\NotificationTypeRegistry;
use Illuminate\Support\ServiceProvider;
class ClientsServiceProvider extends ServiceProvider
{
public function boot(): void
{
// In-app only, the same reasoning client_uploaded gives: email for
// this event is already sent separately, to whatever raw addresses
// Setting::AdminNotificationEmails lists, via
// AdminClientRegisteredNotification. Routing it through Notifier's
// mail dispatch as well would risk double-emailing any staff member
// who also appears in that list.
//
// One type for both doors, deliberately. 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: preferences here
// govern email only (see NotificationPreferences), so it could not
// be switched off separately, and which door it came through is one
// click away in the activity log and on the invitations screen.
$this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition(
key: 'client_registered',
label: 'A new client registered an account',
template: ':clientName (:clientEmail) registered a client account',
// The list, filtered to this address — not clients.edit, which
// is gated by edit_clients while the recipients below are chosen
// by manage_clients. A notification that refuses the person it
// was sent to is worse than one that lands a click short.
url: fn (array $data): string => route('clients.index', ['search' => $data['clientEmail']]),
));
}
}
+2
View File
@@ -2,6 +2,7 @@
use App\Modules\Api\ApiServiceProvider;
use App\Modules\Audit\AuditServiceProvider;
use App\Modules\Clients\ClientsServiceProvider;
use App\Modules\Comments\CommentsServiceProvider;
use App\Modules\Files\FilesServiceProvider;
use App\Modules\Groups\GroupsServiceProvider;
@@ -14,6 +15,7 @@ use App\Providers\AppServiceProvider;
return [
ApiServiceProvider::class,
AuditServiceProvider::class,
ClientsServiceProvider::class,
CommentsServiceProvider::class,
FilesServiceProvider::class,
GroupsServiceProvider::class,
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "T'han convidat a registrar-te",
"You've been invited to register a client account. The link below will let you set your own password.": "T'han convidat a registrar un compte de client. L'enllaç de sota et permetrà triar la teva pròpia contrasenya.",
"Your account has been created. You will be able to log in once it is approved.": "S'ha creat el teu compte. Podràs iniciar la sessió quan s'aprovi.",
"Invite a client to share files with": "Convida un client amb qui compartir fitxers"
"Invite a client to share files with": "Convida un client amb qui compartir fitxers",
":clientName (:clientEmail) registered a client account": "Ha registrat un compte de client :clientName (:clientEmail)",
"A new client registered an account": "S'ha registrat un compte de client nou"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Byli jste pozváni k registraci",
"You've been invited to register a client account. The link below will let you set your own password.": "Byli jste pozváni k registraci klientského účtu. Odkaz níže vám umožní zvolit si vlastní heslo.",
"Your account has been created. You will be able to log in once it is approved.": "Váš účet byl vytvořen. Přihlásit se budete moci, jakmile bude schválen.",
"Invite a client to share files with": "Pozvěte klienta, se kterým budete sdílet soubory"
"Invite a client to share files with": "Pozvěte klienta, se kterým budete sdílet soubory",
":clientName (:clientEmail) registered a client account": "Klient :clientName (:clientEmail) si zaregistroval účet",
"A new client registered an account": "Zaregistroval se nový klient"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Sie wurden zur Registrierung eingeladen",
"You've been invited to register a client account. The link below will let you set your own password.": "Sie wurden eingeladen, ein Kundenkonto zu registrieren. Über den Link unten können Sie Ihr eigenes Passwort festlegen.",
"Your account has been created. You will be able to log in once it is approved.": "Ihr Konto wurde erstellt. Sie können sich anmelden, sobald es genehmigt wurde.",
"Invite a client to share files with": "Laden Sie einen Kunden ein, mit dem Sie Dateien teilen"
"Invite a client to share files with": "Laden Sie einen Kunden ein, mit dem Sie Dateien teilen",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) hat ein Kundenkonto registriert",
"A new client registered an account": "Ein neuer Kunde hat ein Konto registriert"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Te han invitado a registrarte",
"You've been invited to register a client account. The link below will let you set your own password.": "Te han invitado a registrar una cuenta de cliente. El enlace de abajo te permitirá elegir tu propia contraseña.",
"Your account has been created. You will be able to log in once it is approved.": "Tu cuenta ha sido creada. Podrás iniciar sesión en cuanto se apruebe.",
"Invite a client to share files with": "Invita a un cliente con quien compartir archivos"
"Invite a client to share files with": "Invita a un cliente con quien compartir archivos",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) registró una cuenta de cliente",
"A new client registered an account": "Un cliente nuevo registró una cuenta"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Vous avez été invité à vous inscrire",
"You've been invited to register a client account. The link below will let you set your own password.": "Vous avez été invité à créer un compte client. Le lien ci-dessous vous permettra de choisir votre propre mot de passe.",
"Your account has been created. You will be able to log in once it is approved.": "Votre compte a été créé. Vous pourrez vous connecter une fois qu'il aura été approuvé.",
"Invite a client to share files with": "Invitez un client avec qui partager des fichiers"
"Invite a client to share files with": "Invitez un client avec qui partager des fichiers",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) a créé un compte client",
"A new client registered an account": "Un nouveau client a créé un compte"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Anda diundang untuk mendaftar",
"You've been invited to register a client account. The link below will let you set your own password.": "Anda diundang untuk mendaftarkan akun klien. Tautan di bawah ini memungkinkan Anda menetapkan kata sandi sendiri.",
"Your account has been created. You will be able to log in once it is approved.": "Akun Anda telah dibuat. Anda bisa masuk setelah akun disetujui.",
"Invite a client to share files with": "Undang klien untuk berbagi berkas"
"Invite a client to share files with": "Undang klien untuk berbagi berkas",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) mendaftarkan akun klien",
"A new client registered an account": "Klien baru mendaftarkan akun"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Sei stato invitato a registrarti",
"You've been invited to register a client account. The link below will let you set your own password.": "Sei stato invitato a registrare un account cliente. Il link qui sotto ti permetterà di scegliere la tua password.",
"Your account has been created. You will be able to log in once it is approved.": "Il tuo account è stato creato. Potrai accedere non appena verrà approvato.",
"Invite a client to share files with": "Invita un cliente con cui condividere i file"
"Invite a client to share files with": "Invita un cliente con cui condividere i file",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) ha registrato un account cliente",
"A new client registered an account": "Un nuovo cliente ha registrato un account"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "登録のご招待",
"You've been invited to register a client account. The link below will let you set your own password.": "クライアントアカウントの登録に招待されました。下のリンクからご自身のパスワードを設定できます。",
"Your account has been created. You will be able to log in once it is approved.": "アカウントを作成しました。承認されるとログインできます。",
"Invite a client to share files with": "ファイルを共有するクライアントを招待します"
"Invite a client to share files with": "ファイルを共有するクライアントを招待します",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) がクライアントアカウントを登録しました",
"A new client registered an account": "新しいクライアントがアカウントを登録しました"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Je bent uitgenodigd om je te registreren",
"You've been invited to register a client account. The link below will let you set your own password.": "Je bent uitgenodigd om een klantaccount te registreren. Via de onderstaande link kun je je eigen wachtwoord instellen.",
"Your account has been created. You will be able to log in once it is approved.": "Je account is aangemaakt. Je kunt inloggen zodra het is goedgekeurd.",
"Invite a client to share files with": "Nodig een klant uit om bestanden mee te delen"
"Invite a client to share files with": "Nodig een klant uit om bestanden mee te delen",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) heeft een klantaccount geregistreerd",
"A new client registered an account": "Een nieuwe klant heeft een account geregistreerd"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Zaproszono Cię do rejestracji",
"You've been invited to register a client account. The link below will let you set your own password.": "Zaproszono Cię do zarejestrowania konta klienta. Poniższy link pozwoli Ci ustawić własne hasło.",
"Your account has been created. You will be able to log in once it is approved.": "Twoje konto zostało utworzone. Zalogujesz się, gdy zostanie zatwierdzone.",
"Invite a client to share files with": "Zaproś klienta, któremu będziesz udostępniać pliki"
"Invite a client to share files with": "Zaproś klienta, któremu będziesz udostępniać pliki",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) zarejestrował konto klienta",
"A new client registered an account": "Nowy klient zarejestrował konto"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Você foi convidado a se cadastrar",
"You've been invited to register a client account. The link below will let you set your own password.": "Você foi convidado a cadastrar uma conta de cliente. O link abaixo permitirá que você defina sua própria senha.",
"Your account has been created. You will be able to log in once it is approved.": "Sua conta foi criada. Você poderá entrar assim que ela for aprovada.",
"Invite a client to share files with": "Convide um cliente com quem compartilhar arquivos"
"Invite a client to share files with": "Convide um cliente com quem compartilhar arquivos",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) cadastrou uma conta de cliente",
"A new client registered an account": "Um novo cliente cadastrou uma conta"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Вас пригласили зарегистрироваться",
"You've been invited to register a client account. The link below will let you set your own password.": "Вас пригласили зарегистрировать учётную запись клиента. Ссылка ниже позволит вам задать собственный пароль.",
"Your account has been created. You will be able to log in once it is approved.": "Ваша учётная запись создана. Вы сможете войти, как только её одобрят.",
"Invite a client to share files with": "Пригласите клиента, с которым будете делиться файлами"
"Invite a client to share files with": "Пригласите клиента, с которым будете делиться файлами",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) зарегистрировал учётную запись клиента",
"A new client registered an account": "Новый клиент зарегистрировал учётную запись"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Umealikwa kujisajili",
"You've been invited to register a client account. The link below will let you set your own password.": "Umealikwa kusajili akaunti ya mteja. Kiungo kilicho hapa chini kitakuruhusu kuweka nenosiri lako mwenyewe.",
"Your account has been created. You will be able to log in once it is approved.": "Akaunti yako imeundwa. Utaweza kuingia mara itakapoidhinishwa.",
"Invite a client to share files with": "Alika mteja wa kushirikiana naye mafaili"
"Invite a client to share files with": "Alika mteja wa kushirikiana naye mafaili",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) amesajili akaunti ya mteja",
"A new client registered an account": "Mteja mpya amesajili akaunti"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Kaydolmaya davet edildiniz",
"You've been invited to register a client account. The link below will let you set your own password.": "Bir müşteri hesabı oluşturmaya davet edildiniz. Aşağıdaki bağlantı kendi parolanızı belirlemenizi sağlar.",
"Your account has been created. You will be able to log in once it is approved.": "Hesabınız oluşturuldu. Onaylandığında giriş yapabileceksiniz.",
"Invite a client to share files with": "Dosya paylaşacağınız bir müşteriyi davet edin"
"Invite a client to share files with": "Dosya paylaşacağınız bir müşteriyi davet edin",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) bir müşteri hesabı oluşturdu",
"A new client registered an account": "Yeni bir müşteri hesap oluşturdu"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "Bạn được mời đăng ký",
"You've been invited to register a client account. The link below will let you set your own password.": "Bạn được mời đăng ký tài khoản khách hàng. Liên kết bên dưới sẽ cho phép bạn tự đặt mật khẩu.",
"Your account has been created. You will be able to log in once it is approved.": "Tài khoản của bạn đã được tạo. Bạn có thể đăng nhập khi tài khoản được duyệt.",
"Invite a client to share files with": "Mời một khách hàng để chia sẻ tệp"
"Invite a client to share files with": "Mời một khách hàng để chia sẻ tệp",
":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) đã đăng ký tài khoản khách hàng",
"A new client registered an account": "Một khách hàng mới đã đăng ký tài khoản"
}
+3 -1
View File
@@ -2073,5 +2073,7 @@
"You've been invited to register": "邀请你注册",
"You've been invited to register a client account. The link below will let you set your own password.": "你受邀注册客户账户。通过下面的链接可以设置你自己的密码。",
"Your account has been created. You will be able to log in once it is approved.": "你的账户已创建,通过审核后即可登录。",
"Invite a client to share files with": "邀请一位与你共享文件的客户"
"Invite a client to share files with": "邀请一位与你共享文件的客户",
":clientName (:clientEmail) registered a client account": ":clientName:clientEmail)注册了客户账户",
"A new client registered an account": "新客户注册了账户"
}
@@ -8,7 +8,11 @@ use App\Modules\Audit\ActivityLog;
use App\Modules\Clients\Models\Invitation;
use App\Modules\Clients\Notifications\ClientInvitationNotification;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\InAppNotification;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Routing\Middleware\ThrottleRequests;
@@ -478,3 +482,43 @@ test('the invite form and the history are separate screens', function () {
fn (AssertableInertia $page) => $page->component('clients/invitations')->has('invitations')->missing('groups'),
);
});
test('redeeming an invitation puts a notification in front of the staff who administer clients', function () {
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
$this->post("/invite/{$invitation->token}", [
'token' => $invitation->token,
'name' => 'Invited Person',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
])->assertRedirect(route('login'));
$client = User::query()->where('email', 'invited@example.com')->sole();
$notification = InAppNotification::query()->where('type', 'client_registered')->sole();
expect($notification->user_id)->toBe($this->admin->id)
->and($notification->subject_id)->toBe($client->id)
->and($notification->data['clientName'])->toBe('Invited Person')
->and($notification->data['clientEmail'])->toBe('invited@example.com');
});
test('a client-scoped staff member is not told about an account assigned to nobody', function () {
$role = Role::query()->create(['name' => 'Scoped Manager', 'client_scoped' => true]);
RolePermission::query()->insert([
['role_id' => $role->id, 'permission' => Permission::ManageClients->value],
]);
$scoped = User::factory()->create(['role_id' => $role->id]);
$invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay());
$this->post("/invite/{$invitation->token}", [
'token' => $invitation->token,
'name' => 'Invited Person',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
]);
expect(InAppNotification::query()->where('type', 'client_registered')->pluck('user_id')->all())
->toBe([$this->admin->id])
->and(InAppNotification::query()->where('user_id', $scoped->id)->exists())->toBeFalse();
});