From 856c13b09cb084ebc8ec2aee87496969cf93d071 Mon Sep 17 00:00:00 2001 From: mash2k3 Date: Sat, 12 Sep 2026 13:28:47 -0400 Subject: [PATCH] Invite a client to register instead of handing them a password (#1780) Staff can now invite a specific address to register instead of typing a password for somebody and finding a way to get it to them. The invited person sets their own, the link is locked to the address it was sent to, and an invitation always activates the account regardless of the auto-approve setting -- naming an address is already the decision the approval queue exists to make for one nobody named. Two fixes ride along: outgoing mail now reads the installation's own site name in its title, header and signature rather than the one baked into config('app.name') at install time, and the CSRF cookie name is read per request rather than captured once at load. Follow-up work, tracked separately: an invitation cannot be cancelled -- there is no pending-invitations screen and no revoke, so letting one expire is the only way to take it back, which the self-service resend button then undoes. Redemption also needs the address-availability check every other non-form caller of ClientProvisioning makes. Thanks @mash2k3. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe --- app/Modules/Audit/Action.php | 6 + app/Modules/Clients/ClientProvisioning.php | 12 +- .../Controllers/ClientSettingsController.php | 3 + .../Http/Controllers/InvitationController.php | 81 ++++++ .../InvitationRedemptionController.php | 149 +++++++++++ app/Modules/Clients/Models/Invitation.php | 110 ++++++++ .../ClientInvitationNotification.php | 50 ++++ .../Controllers/EmailTemplatesController.php | 2 + .../Notifications/EmailTemplateSlot.php | 5 + app/Modules/Platform/Settings/Setting.php | 8 + ..._09_12_090000_create_invitations_table.php | 40 +++ docs/api/openapi.json | 2 + resources/js/app.tsx | 12 +- resources/js/pages/auth/invite.tsx | 139 ++++++++++ resources/js/pages/clients/index.tsx | 5 + resources/js/pages/clients/invite.tsx | 125 +++++++++ .../js/pages/system/settings/clients.tsx | 20 ++ .../views/vendor/mail/html/layout.blade.php | 73 ++++++ .../views/vendor/mail/html/message.blade.php | 42 +++ .../views/vendor/mail/text/message.blade.php | 42 +++ .../vendor/notifications/email.blade.php | 32 ++- routes/auth.php | 15 ++ routes/web.php | 6 + .../Feature/Clients/ClientInvitationTest.php | 240 ++++++++++++++++++ .../Feature/Clients/ClientsManagementTest.php | 1 + .../Feature/Groups/MembershipRequestsTest.php | 1 + tests/Feature/Platform/EmailThemingTest.php | 17 ++ 27 files changed, 1225 insertions(+), 13 deletions(-) create mode 100644 app/Modules/Clients/Http/Controllers/InvitationController.php create mode 100644 app/Modules/Clients/Http/Controllers/InvitationRedemptionController.php create mode 100644 app/Modules/Clients/Models/Invitation.php create mode 100644 app/Modules/Clients/Notifications/ClientInvitationNotification.php create mode 100644 database/migrations/2026_09_12_090000_create_invitations_table.php create mode 100644 resources/js/pages/auth/invite.tsx create mode 100644 resources/js/pages/clients/invite.tsx create mode 100644 resources/views/vendor/mail/html/layout.blade.php create mode 100644 resources/views/vendor/mail/html/message.blade.php create mode 100644 resources/views/vendor/mail/text/message.blade.php create mode 100644 tests/Feature/Clients/ClientInvitationTest.php diff --git a/app/Modules/Audit/Action.php b/app/Modules/Audit/Action.php index cad3329a..552ff412 100644 --- a/app/Modules/Audit/Action.php +++ b/app/Modules/Audit/Action.php @@ -40,6 +40,8 @@ enum Action: string case SocialAccountUnlinked = 'social.account_unlinked'; case ClientApproved = 'client.approved'; case ClientDenied = 'client.denied'; + case ClientInvited = 'client.invited'; + case ClientInvitationRedeemed = 'client.invitation_redeemed'; // Files case FileUploaded = 'file.uploaded'; case FileUpdated = 'file.updated'; @@ -165,6 +167,8 @@ enum Action: string self::ClientSelfRegistered => 'Registered a new client account', self::ClientApproved => 'Approved the account request of ":subject"', self::ClientDenied => 'Denied the account request of ":name"', + self::ClientInvited => 'Invited :email to register a client account', + self::ClientInvitationRedeemed => 'Registered a client account from an invitation', self::FileUploaded => 'Uploaded the file ":subject"', self::FileUpdated => 'Updated the file ":subject"', self::FileDeleted => 'Deleted the file ":name"', @@ -267,6 +271,8 @@ enum Action: string self::ClientSelfRegistered => 'A client registered an account', self::ClientApproved => 'A client account request was approved', self::ClientDenied => 'A client account request was denied', + self::ClientInvited => 'A client was invited to register an account', + self::ClientInvitationRedeemed => 'A client registered an account from an invitation', self::FileUploaded => 'A file was uploaded', self::FileUpdated => 'A file was updated', self::FileDeleted => 'A file was deleted', diff --git a/app/Modules/Clients/ClientProvisioning.php b/app/Modules/Clients/ClientProvisioning.php index eaf7254c..d4e73484 100644 --- a/app/Modules/Clients/ClientProvisioning.php +++ b/app/Modules/Clients/ClientProvisioning.php @@ -13,8 +13,8 @@ use App\Modules\Identity\AuthSource; use App\Modules\Identity\Models\Role; use App\Modules\Identity\Permissions\SystemRole; use App\Modules\Identity\UserType; -use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Seats\SeatAllowance; +use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Settings; use Illuminate\Support\Facades\Notification; @@ -75,6 +75,14 @@ class ClientProvisioning * @param array $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, @@ -85,6 +93,7 @@ class ClientProvisioning ?string $ldapDn = null, ?bool $autoApprove = null, array $context = [], + int $storageQuotaMb = 0, ): User { $autoApprove ??= $this->autoApproves(); @@ -105,6 +114,7 @@ class ClientProvisioning 'name' => $name, 'email' => $email, 'password' => $password, + 'storage_quota_mb' => $storageQuotaMb, ]); // Not mass-assignable: where an account's credentials live is a diff --git a/app/Modules/Clients/Http/Controllers/ClientSettingsController.php b/app/Modules/Clients/Http/Controllers/ClientSettingsController.php index 9d2bc6ad..17a651a2 100644 --- a/app/Modules/Clients/Http/Controllers/ClientSettingsController.php +++ b/app/Modules/Clients/Http/Controllers/ClientSettingsController.php @@ -31,6 +31,7 @@ class ClientSettingsController extends Controller 'clients_auto_group' => $this->settings->get(Setting::ClientsAutoGroup), 'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup), 'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays), + 'client_invitation_expiry_hours' => $this->settings->get(Setting::ClientInvitationExpiryHours), 'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb), 'clients_can_preview_files' => $this->settings->get(Setting::ClientsCanPreviewFiles), 'groups' => Group::query()->orderBy('name')->get() @@ -47,6 +48,7 @@ class ClientSettingsController extends Controller 'clients_auto_group' => ['required', 'integer', Rule::in([0, ...Group::query()->pluck('id')->all()])], 'clients_can_select_group' => ['required', Rule::in(['none', 'public'])], 'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'], + 'client_invitation_expiry_hours' => ['required', 'integer', 'min:1', 'max:720'], 'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'], 'clients_can_preview_files' => ['required', 'boolean'], ]); @@ -56,6 +58,7 @@ class ClientSettingsController extends Controller $this->settings->set(Setting::ClientsAutoGroup, (int) $validated['clients_auto_group']); $this->settings->set(Setting::ClientsCanSelectGroup, $validated['clients_can_select_group']); $this->settings->set(Setting::ClientsMembershipDenyCooldownDays, (int) $validated['clients_membership_deny_cooldown_days']); + $this->settings->set(Setting::ClientInvitationExpiryHours, (int) $validated['client_invitation_expiry_hours']); $this->settings->set(Setting::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']); $this->settings->set(Setting::ClientsCanPreviewFiles, $validated['clients_can_preview_files']); diff --git a/app/Modules/Clients/Http/Controllers/InvitationController.php b/app/Modules/Clients/Http/Controllers/InvitationController.php new file mode 100644 index 00000000..fc2ccabc --- /dev/null +++ b/app/Modules/Clients/Http/Controllers/InvitationController.php @@ -0,0 +1,81 @@ + Group::query()->orderBy('name')->get(['id', 'name']), + // Resolved, not raw — see ClientsController::create()'s note on + // the same prop: this is what will actually happen, and the + // form's own field mirrors this resolution to draw its hint. + 'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule], + 'name' => ['nullable', 'string', 'max:255'], + 'group_id' => ['required', 'integer', Rule::in([0, ...Group::query()->pluck('id')->all()])], + 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + ]); + + $group = $validated['group_id'] > 0 + ? Group::query()->whereKey($validated['group_id'])->first() + : null; + + $invitation = Invitation::issue( + email: $validated['email'], + name: $validated['name'] ?? null, + group: $group, + invitedBy: $request->user(), + expiresAt: now()->addHours((int) $this->settings->get(Setting::ClientInvitationExpiryHours)), + // The `integer` rule above validates the shape but does not + // cast it — this arrives as a numeric string from the request, + // same as group_id, and issue() takes a real int. + storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0), + ); + + Notification::route('mail', $invitation->email)->notify( + new ClientInvitationNotification($invitation->name ?? $invitation->email, $invitation->token), + ); + + $this->activity->log(Action::ClientInvited, context: ['email' => $invitation->email]); + + return redirect()->route('clients.index')->with('success', __('Invitation sent.')); + } +} diff --git a/app/Modules/Clients/Http/Controllers/InvitationRedemptionController.php b/app/Modules/Clients/Http/Controllers/InvitationRedemptionController.php new file mode 100644 index 00000000..a24bd63a --- /dev/null +++ b/app/Modules/Clients/Http/Controllers/InvitationRedemptionController.php @@ -0,0 +1,149 @@ +route('token'); + $invitation = $this->findUsable($token); + + return Inertia::render('auth/invite', [ + 'token' => $token, + 'email' => $invitation->email ?? '', + 'name' => $invitation->name ?? '', + 'status' => $request->session()->get('status'), + // Same shape as NewPasswordController::create()'s $expired: one + // answer for "no such token" and "spent or expired token", + // because telling them apart would tell a guesser which + // addresses this installation has invited. + 'expired' => $invitation === null, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'token' => ['required', 'string'], + 'name' => ['required', 'string', 'max:255'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]); + + $invitation = $this->findUsable($validated['token']); + + if ($invitation === null) { + throw ValidationException::withMessages([ + 'token' => [__('This invitation is no longer valid. Ask whoever invited you to send a new one.')], + ]); + } + + $client = $this->provisioning->provision( + name: $validated['name'], + email: $invitation->email, + password: $validated['password'], + action: Action::ClientInvitationRedeemed, + // Always, regardless of Setting::ClientsAutoApprove: an + // invitation names a specific address a staff member already + // decided to let in, which is the trust an approval queue + // exists to establish for the address it never named. + autoApprove: true, + storageQuotaMb: $invitation->storage_quota_mb, + ); + + if ($invitation->group !== null) { + $invitation->group->members()->syncWithoutDetaching([$client->id]); + } + + $invitation->forceFill(['status' => Invitation::STATUS_REDEEMED])->save(); + + return redirect()->route('login')->with( + 'status', + $client->account_requested + ? __('Your account has been created. You will be able to log in once it is approved.') + : __('Your account has been created. You can log in now.'), + ); + } + + /** + * Resends a fresh link to the same address without anybody deciding + * to — the invited person asked for it, not an administrator. A spent + * or genuinely unknown token answers the same as an expired one: this + * is the one door on the flow an anonymous visitor can knock on + * repeatedly, so it must not become a way to learn which addresses + * were ever invited. + */ + public function resend(Request $request): RedirectResponse + { + $token = (string) $request->route('token'); + $invitation = $this->findUsable($token, includingExpired: true); + + if ($invitation !== null) { + $fresh = Invitation::issue( + email: $invitation->email, + name: $invitation->name, + group: $invitation->group, + invitedBy: $invitation->invitedBy, + expiresAt: now()->addHours((int) $this->settings->get(Setting::ClientInvitationExpiryHours)), + storageQuotaMb: $invitation->storage_quota_mb, + ); + + Notification::route('mail', $fresh->email)->notify( + new ClientInvitationNotification($fresh->name ?? $fresh->email, $fresh->token), + ); + } + + return back()->with('status', __('If that invitation can still be resent, a new one is on its way.')); + } + + /** + * The invitation $token names, if it is still one store() would + * accept — pending and not expired, unless $includingExpired asks for + * the resend door's wider question instead. + */ + private function findUsable(string $token, bool $includingExpired = false): ?Invitation + { + $invitation = Invitation::query()->pending()->where('token', $token)->first(); + + if ($invitation === null) { + return null; + } + + if (! $includingExpired && $invitation->isExpired()) { + return null; + } + + return $invitation; + } +} diff --git a/app/Modules/Clients/Models/Invitation.php b/app/Modules/Clients/Models/Invitation.php new file mode 100644 index 00000000..0e51a0ae --- /dev/null +++ b/app/Modules/Clients/Models/Invitation.php @@ -0,0 +1,110 @@ + 'datetime', + ]; + } + + /** + * A fresh invitation for $email, retiring any other still-pending one + * for the same address first — one live token per address at a time, + * whether this is staff sending a second invite or the invited person + * asking for a new link after the first expired. + */ + public static function issue(string $email, ?string $name, ?Group $group, ?User $invitedBy, Carbon $expiresAt, int $storageQuotaMb = 0): self + { + self::query()->pending()->where('email', $email)->update(['status' => self::STATUS_SUPERSEDED]); + + return self::query()->create([ + 'name' => $name, + 'email' => $email, + 'token' => Str::random(40), + 'status' => self::STATUS_PENDING, + 'storage_quota_mb' => $storageQuotaMb, + 'group_id' => $group?->id, + 'invited_by_id' => $invitedBy?->id, + 'expires_at' => $expiresAt, + ]); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopePending(Builder $query): Builder + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * @return BelongsTo + */ + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + /** + * @return BelongsTo + */ + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_id'); + } +} diff --git a/app/Modules/Clients/Notifications/ClientInvitationNotification.php b/app/Modules/Clients/Notifications/ClientInvitationNotification.php new file mode 100644 index 00000000..b913c3ce --- /dev/null +++ b/app/Modules/Clients/Notifications/ClientInvitationNotification.php @@ -0,0 +1,50 @@ +notify() — there is no account yet to notify, only an address + * somebody typed into the invite form. + */ +class ClientInvitationNotification extends Notification implements ShouldQueue +{ + use Queueable, RendersOverridableMail; + + public function __construct( + private readonly string $name, + private readonly string $token, + ) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $url = route('invitations.show', $this->token); + + if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientInvited)) !== null) { + return $this->mailFromOverride($override, [':name' => $this->name])->action(__('Register'), $url); + } + + return (new MailMessage) + ->subject(__("You've been invited to register")) + ->greeting(__('Hello :name,', ['name' => $this->name])) + ->line(__("You've been invited to register a client account. The link below will let you set your own password.")) + ->action(__('Register'), $url); + } +} diff --git a/app/Modules/Platform/Http/Controllers/EmailTemplatesController.php b/app/Modules/Platform/Http/Controllers/EmailTemplatesController.php index 5f89c4be..9cfb2d43 100644 --- a/app/Modules/Platform/Http/Controllers/EmailTemplatesController.php +++ b/app/Modules/Platform/Http/Controllers/EmailTemplatesController.php @@ -12,6 +12,7 @@ use App\Modules\Clients\Notifications\AdminClientRegisteredNotification; use App\Modules\Clients\Notifications\ClientAccountApprovedNotification; use App\Modules\Clients\Notifications\ClientAccountDeniedNotification; use App\Modules\Clients\Notifications\ClientAccountEditedNotification; +use App\Modules\Clients\Notifications\ClientInvitationNotification; use App\Modules\Clients\Notifications\ClientWelcomeNotification; use App\Modules\Comments\Notifications\CommentDigestNotification; use App\Modules\Comments\Notifications\CommentPostedNotification; @@ -136,6 +137,7 @@ class EmailTemplatesController extends Controller EmailTemplateSlot::ClientAccountApproved => (new ClientAccountApprovedNotification)->toMail($notifiable), EmailTemplateSlot::ClientAccountDenied => (new ClientAccountDeniedNotification('Jane Client'))->toMail($notifiable), EmailTemplateSlot::ClientWelcome => (new ClientWelcomeNotification)->toMail($notifiable), + EmailTemplateSlot::ClientInvited => (new ClientInvitationNotification('Jane Client', 'sample-token'))->toMail($notifiable), EmailTemplateSlot::ClientAccountEdited => (new ClientAccountEditedNotification)->toMail($notifiable), EmailTemplateSlot::AdminClientRegistered => (new AdminClientRegisteredNotification('Jane Client', 'preview@example.com', pendingApproval: false))->toMail($notifiable), EmailTemplateSlot::AdminClientUploaded => (new AdminClientUploadedNotification('Jane Client', 'sample-file.pdf', 0))->toMail($notifiable), diff --git a/app/Modules/Platform/Notifications/EmailTemplateSlot.php b/app/Modules/Platform/Notifications/EmailTemplateSlot.php index 92b2dc1a..00cb21e5 100644 --- a/app/Modules/Platform/Notifications/EmailTemplateSlot.php +++ b/app/Modules/Platform/Notifications/EmailTemplateSlot.php @@ -28,6 +28,7 @@ enum EmailTemplateSlot: string case ClientAccountApproved = 'client_account_approved'; case ClientAccountDenied = 'client_account_denied'; case ClientWelcome = 'client_welcome'; + case ClientInvited = 'client_invited'; case ClientAccountEdited = 'client_account_edited'; case AdminClientRegistered = 'admin_client_registered'; case AdminClientUploaded = 'admin_client_uploaded'; @@ -48,6 +49,7 @@ enum EmailTemplateSlot: string self::ClientAccountApproved => 'Client account approved', self::ClientAccountDenied => 'Client account denied', self::ClientWelcome => 'Client welcome (staff-created account)', + self::ClientInvited => 'Client invitation', self::ClientAccountEdited => 'Client account edited', self::AdminClientRegistered => 'Admin: new client registered', self::AdminClientUploaded => 'Admin: client uploaded a file', @@ -72,6 +74,7 @@ enum EmailTemplateSlot: string self::ClientAccountApproved => 'Your account has been approved', self::ClientAccountDenied => 'Your account request was denied', self::ClientWelcome => 'Welcome', + self::ClientInvited => "You've been invited to register", self::ClientAccountEdited => 'Your account was updated', self::AdminClientRegistered => 'A new client has registered', self::AdminClientUploaded => 'A client uploaded a file', @@ -94,6 +97,7 @@ enum EmailTemplateSlot: string self::ClientAccountApproved => 'Your account request has been approved. You can now log in.', self::ClientAccountDenied => "Hello :name,\n\nYour account request has been denied.", self::ClientWelcome => 'An account has been created for you. You can log in now.', + self::ClientInvited => "Hello :name,\n\nYou've been invited to register a client account. The link below will let you set your own password.", self::ClientAccountEdited => 'Your account details were recently changed by an administrator. Contact your administrator if this was not expected.', self::AdminClientRegistered => 'A new client account was created: :name (:email).', self::AdminClientUploaded => 'The file ":file" was uploaded by :client.', @@ -119,6 +123,7 @@ enum EmailTemplateSlot: string self::CommentDigest => ['count' => 'How many new comments there are'], self::ClientAccountApproved, self::ClientWelcome, self::ClientAccountEdited => [], self::ClientAccountDenied => ['name' => "The client's name"], + self::ClientInvited => ['name' => "The invited person's name, or their email address if none was given"], self::AdminClientRegistered => ['name' => "The client's name", 'email' => "The client's email address"], self::AdminClientUploaded => ['file' => 'The file name', 'client' => "The uploading client's name"], self::GroupMembershipRequested => ['client' => "The client's name", 'group' => 'The group name'], diff --git a/app/Modules/Platform/Settings/Setting.php b/app/Modules/Platform/Settings/Setting.php index 25bc5fcb..d3fec14e 100644 --- a/app/Modules/Platform/Settings/Setting.php +++ b/app/Modules/Platform/Settings/Setting.php @@ -30,6 +30,12 @@ enum Setting: string // Days a denied membership request blocks re-requesting (0 = none). case ClientsMembershipDenyCooldownDays = 'clients_membership_deny_cooldown_days'; + // How long a staff-sent client invitation link stays valid before the + // invited address has to ask for a new one. Consumed by + // InvitationController and InvitationRedemptionController, wherever a + // fresh Invitation is issued. + case ClientInvitationExpiryHours = 'client_invitation_expiry_hours'; + // Whether the client portal offers inline preview at all — the whole // affordance, images included, not just the media types. Staff are // never gated by it: it exists so an installation can decide that a @@ -392,6 +398,7 @@ enum Setting: string self::ClientsAutoGroup, self::ClientsMembershipDenyCooldownDays, + self::ClientInvitationExpiryHours, self::MaxFileSizeMb, self::MaxZipDownloadSizeMb, self::DefaultClientStorageQuotaMb, @@ -462,6 +469,7 @@ enum Setting: string self::ClientsAutoGroup => 0, self::ClientsMembershipDenyCooldownDays => 30, + self::ClientInvitationExpiryHours => 72, self::MaxFileSizeMb => 2048, self::MaxZipDownloadSizeMb => 2048, self::DefaultClientStorageQuotaMb => 0, diff --git a/database/migrations/2026_09_12_090000_create_invitations_table.php b/database/migrations/2026_09_12_090000_create_invitations_table.php new file mode 100644 index 00000000..1d2d9941 --- /dev/null +++ b/database/migrations/2026_09_12_090000_create_invitations_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('name')->nullable(); + $table->string('email'); + $table->string('token', 64)->unique(); + $table->string('status')->default('pending'); + // 0 means no per-account quota and inherits the site default + // at enforcement time, the same as ClientAccounts::create()'s + // storageQuotaMb — see Invitation::issue(). + $table->unsignedInteger('storage_quota_mb')->default(0); + $table->foreignId('group_id')->nullable()->constrained('groups')->nullOnDelete(); + $table->foreignId('invited_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->index(['email', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('invitations'); + } +}; diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 2bffd2b1..6c8d9b2c 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -3530,6 +3530,8 @@ "social.account_unlinked", "client.approved", "client.denied", + "client.invited", + "client.invitation_redeemed", "file.uploaded", "file.updated", "file.deleted", diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 77597e19..a1343438 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -17,7 +17,17 @@ declare global { // names that cookie after itself so a neighbouring Laravel app on the same // hostname cannot overwrite it — so axios has to be told. Without this, // every write 419s the moment a neighbour answers a request. -axios.defaults.xsrfCookieName = xsrfCookieName(); +// +// Set on every request rather than once here: an SPA-style Inertia visit +// (a redirect after a POST, for instance) never re-runs this module, so a +// value captured once at load can go stale the moment the server rotates +// the cookie mid-session — the exact failure xsrf.ts's own docblock warns +// about, and the reason it says to read the name fresh on every call. +axios.interceptors.request.use((config) => { + config.xsrfCookieName = xsrfCookieName(); + + return config; +}); /** * The suffix on every browser tab title. diff --git a/resources/js/pages/auth/invite.tsx b/resources/js/pages/auth/invite.tsx new file mode 100644 index 00000000..5754a8a7 --- /dev/null +++ b/resources/js/pages/auth/invite.tsx @@ -0,0 +1,139 @@ +import { Head, useForm } from '@inertiajs/react'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler } from 'react'; + +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { PasswordRequirements } from '@/components/password-requirements'; +import { useTranslation } from '@/hooks/use-translation'; +import AuthLayout from '@/layouts/auth-layout'; + +interface InviteProps { + token: string; + email: string; + name: string; + status: string | null; + /** + * Whether the server already knows this link will be refused. False + * for a token it cannot place, which is not the same thing — see + * InvitationRedemptionController::findUsable(). + */ + expired: boolean; +} + +interface AcceptInvitationForm { + [key: string]: string; + token: string; + name: string; + password: string; + password_confirmation: string; +} + +export default function AcceptInvitation({ token, email, name, status, expired }: InviteProps) { + const { t } = useTranslation(); + + const { data, setData, post, processing, errors, reset } = useForm({ + token: token, + name: name, + password: '', + password_confirmation: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + post(route('invitations.accept', token), { + onFinish: () => reset('password', 'password_confirmation'), + }); + }; + + const resend: FormEventHandler = (e) => { + e.preventDefault(); + post(route('invitations.resend', token)); + }; + + // Said before the work rather than after it, the same courtesy + // reset-password gives an expired link — an invitation legitimately + // sits unopened for a while, and asking for a password only to refuse + // it is a bad minute for somebody who did nothing wrong. + if (expired) { + return ( + + + + {status &&

{status}

} + +
+ +
+
+ ); + } + + return ( + + + +
+
+
+ + +
+ +
+ + setData('name', e.target.value)} + /> + +
+ +
+ + setData('password', e.target.value)} + /> + + +
+ +
+ + setData('password_confirmation', e.target.value)} + /> + +
+ + + + +
+
+
+ ); +} diff --git a/resources/js/pages/clients/index.tsx b/resources/js/pages/clients/index.tsx index f7318461..0e435d4a 100644 --- a/resources/js/pages/clients/index.tsx +++ b/resources/js/pages/clients/index.tsx @@ -74,6 +74,11 @@ export default function ClientsIndex({ clients, pagination, filters, reassign_ca {t('Manage custom fields')} )} + {can('create_clients') && ( + + )} {can('create_clients') && ( ({ + email: '', + name: '', + group_id: '0', + // Empty = inherit the site default rather than baking in today's + // numeric value — see the field's own hint text below. + storage_quota_mb: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + post(route('invitations.store')); + }; + + return ( + + + +
+ + +
+
+ + setData('email', e.target.value)} + required + autoFocus + autoComplete="off" + /> + +
+ +
+ + setData('name', e.target.value)} autoComplete="off" /> + +
+ +
+ + +

{t('The client joins this group as soon as they register.')}

+ +
+ +
+ + setData('storage_quota_mb', e.target.value)} + /> +

+ {default_storage_quota_mb > 0 + ? t('Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.', { + default: default_storage_quota_mb, + }) + : t('Blank = unlimited (no site default is set). Set a value to give this client their own limit.')} +

+ +
+ +
+ +
+
+
+
+ ); +} diff --git a/resources/js/pages/system/settings/clients.tsx b/resources/js/pages/system/settings/clients.tsx index 7d2ba667..c8efad52 100644 --- a/resources/js/pages/system/settings/clients.tsx +++ b/resources/js/pages/system/settings/clients.tsx @@ -18,6 +18,7 @@ interface ClientSettingsProps { clients_auto_group: number; clients_can_select_group: string; clients_membership_deny_cooldown_days: number; + client_invitation_expiry_hours: number; default_client_storage_quota_mb: number; clients_can_preview_files: boolean; groups: { id: number; name: string }[]; @@ -29,6 +30,7 @@ export default function ClientSettings({ clients_auto_group, clients_can_select_group, clients_membership_deny_cooldown_days, + client_invitation_expiry_hours, default_client_storage_quota_mb, clients_can_preview_files, groups, @@ -46,6 +48,7 @@ export default function ClientSettings({ clients_auto_group: String(clients_auto_group), clients_can_select_group: clients_can_select_group, clients_membership_deny_cooldown_days: String(clients_membership_deny_cooldown_days), + client_invitation_expiry_hours: String(client_invitation_expiry_hours), default_client_storage_quota_mb: String(default_client_storage_quota_mb), clients_can_preview_files: clients_can_preview_files, }); @@ -157,6 +160,23 @@ export default function ClientSettings({ +
+ + setData('client_invitation_expiry_hours', e.target.value)} + /> +

+ {t('How long a staff-sent invitation stays valid before the invited address has to ask for a new one.')} +

+ +
+
get(Setting::SiteName); +$siteName = is_string($siteName) ? $siteName : 'ProjectSend'; +?> + + + +{{ $siteName }} + + + + + +{!! $head ?? '' !!} + + + + + + + + + + diff --git a/resources/views/vendor/mail/html/message.blade.php b/resources/views/vendor/mail/html/message.blade.php new file mode 100644 index 00000000..3cf659a7 --- /dev/null +++ b/resources/views/vendor/mail/html/message.blade.php @@ -0,0 +1,42 @@ +{{-- Laravel's HTML message layout, published so the header and the + copyright line read the installation's own name instead of the one + baked into config('app.name') at install time. + + This is a copy of a framework view, so it does not follow Laravel + forward on its own. If an upgrade changes the layout, re-copy it and + re-apply the two-line change below. --}} +get(Setting::SiteName); +$siteName = is_string($siteName) ? $siteName : 'ProjectSend'; +?> + +{{-- Header --}} + + +{{ $siteName }} + + + +{{-- Body --}} +{!! $slot !!} + +{{-- Subcopy --}} +@isset($subcopy) + + +{!! $subcopy !!} + + +@endisset + +{{-- Footer --}} + + +© {{ date('Y') }} {{ $siteName }}. {{ __('All rights reserved.') }} + + + diff --git a/resources/views/vendor/mail/text/message.blade.php b/resources/views/vendor/mail/text/message.blade.php new file mode 100644 index 00000000..c76f652c --- /dev/null +++ b/resources/views/vendor/mail/text/message.blade.php @@ -0,0 +1,42 @@ +{{-- Laravel's text message layout, published so the header and the + copyright line read the installation's own name instead of the one + baked into config('app.name') at install time. + + This is a copy of a framework view, so it does not follow Laravel + forward on its own. If an upgrade changes the layout, re-copy it and + re-apply the two-line change below. --}} +get(Setting::SiteName); +$siteName = is_string($siteName) ? $siteName : 'ProjectSend'; +?> + + {{-- Header --}} + + + {{ $siteName }} + + + + {{-- Body --}} + {{ $slot }} + + {{-- Subcopy --}} + @isset($subcopy) + + + {{ $subcopy }} + + + @endisset + + {{-- Footer --}} + + + © {{ date('Y') }} {{ $siteName }}. @lang('All rights reserved.') + + + diff --git a/resources/views/vendor/notifications/email.blade.php b/resources/views/vendor/notifications/email.blade.php index ea1237d2..588e43db 100644 --- a/resources/views/vendor/notifications/email.blade.php +++ b/resources/views/vendor/notifications/email.blade.php @@ -1,17 +1,23 @@ -{{-- Laravel's notification email view, published so the subcopy can spell - the action URL out differently in each half of the message. +{{-- Laravel's notification email view, published for two reasons. - Upstream writes `[$url]($url)` there. That is right for the HTML half - and wrong for the text one, where nothing parses markdown: it arrives - as literal brackets around a duplicated address, which is what a - badly-built phishing mail looks like — on a password reset, often the - first mail an installation ever sends anybody. The x-mail::action-url - component resolves to a different file per half, which is how every - other component in this message already handles the same problem. + The subcopy needs to spell the action URL out differently in each + half of the message. Upstream writes `[$url]($url)` there. That is + right for the HTML half and wrong for the text one, where nothing + parses markdown: it arrives as literal brackets around a duplicated + address, which is what a badly-built phishing mail looks like — on a + password reset, often the first mail an installation ever sends + anybody. The x-mail::action-url component resolves to a different + file per half, which is how every other component in this message + already handles the same problem. + + The salutation's fallback needs to read the installation's own name + rather than the one baked into config('app.name') at install time — + the same reason resources/views/vendor/mail/*/message.blade.php are + published. This is a copy of a framework view, so it does not follow Laravel forward on its own. If an upgrade changes the notification layout, - re-copy it and re-apply the one-line change below. --}} + re-copy it and re-apply the changes below. --}} {{-- Greeting --}} @if (! empty($greeting)) @@ -53,8 +59,12 @@ @if (! empty($salutation)) {{ $salutation }} @else +get(\App\Modules\Platform\Settings\Setting::SiteName); + $siteName = is_string($siteName) ? $siteName : 'ProjectSend'; +?> @lang('Regards,')
-{{ config('app.name') }} +{{ $siteName }} @endif {{-- Subcopy --}} diff --git a/routes/auth.php b/routes/auth.php index 46ddf69c..eea1b4a0 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Auth\EmailVerificationPromptController; use App\Http\Controllers\Auth\NewPasswordController; use App\Http\Controllers\Auth\PasswordResetLinkController; use App\Http\Controllers\Auth\VerifyEmailController; +use App\Modules\Clients\Http\Controllers\InvitationRedemptionController; use App\Modules\Clients\Http\Controllers\RegistrationController; use App\Modules\Identity\Http\Controllers\SocialLoginController; use App\Modules\Identity\Http\Controllers\TwoFactorChallengeController; @@ -36,6 +37,20 @@ Route::middleware('guest')->group(function () { Route::post('register', [RegistrationController::class, 'store']) ->middleware('throttle:6,1,register'); + Route::get('invite/{token}', [InvitationRedemptionController::class, 'create']) + ->name('invitations.show'); + + Route::post('invite/{token}', [InvitationRedemptionController::class, 'store']) + ->middleware('throttle:6,1,invite-accept') + ->name('invitations.accept'); + + // Its own bucket, tighter than accepting one: this is the door an + // anonymous visitor can knock on repeatedly on purpose, since a real + // invitation legitimately expires while nobody is looking. + Route::post('invite/{token}/resend', [InvitationRedemptionController::class, 'resend']) + ->middleware('throttle:3,1,invite-resend') + ->name('invitations.resend'); + Route::get('login', [AuthenticatedSessionController::class, 'create']) ->name('login'); diff --git a/routes/web.php b/routes/web.php index 49f44152..c8cd235a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ use App\Modules\Audit\Http\Controllers\DownloadsController; use App\Modules\Clients\Http\Controllers\AccountRequestsController; use App\Modules\Clients\Http\Controllers\ClientCustomFieldsController; use App\Modules\Clients\Http\Controllers\ClientsController; +use App\Modules\Clients\Http\Controllers\InvitationController; use App\Modules\Comments\Http\Controllers\CommentDeepLinkController; use App\Modules\Comments\Http\Controllers\CommentsController; use App\Modules\Comments\Http\Controllers\FileCommentsController; @@ -273,6 +274,11 @@ Route::middleware(['auth'])->group(function () { Route::get('clients', [ClientsController::class, 'index'])->middleware(['staff', 'can:manage_clients'])->name('clients.index'); Route::get('clients/create', [ClientsController::class, 'create'])->middleware(['staff', 'can:create_clients'])->name('clients.create'); Route::post('clients', [ClientsController::class, 'store'])->middleware(['staff', 'can:create_clients'])->name('clients.store'); + // Invite shares create_clients rather than a capability of its own, + // same reasoning as the note above: an installation that may add a + // client by hand may also ask one to set their own password. + Route::get('clients/invite', [InvitationController::class, 'create'])->middleware(['staff', 'can:create_clients'])->name('invitations.create'); + Route::post('clients/invite', [InvitationController::class, 'store'])->middleware(['staff', 'can:create_clients'])->name('invitations.store'); Route::get('clients/{client}/files', [ClientFilesController::class, 'index'])->middleware(['staff', 'can:edit_clients'])->name('clients.files'); Route::get('clients/{client}', [ClientsController::class, 'edit'])->middleware(['staff', 'can:edit_clients'])->name('clients.edit'); Route::patch('clients/{client}', [ClientsController::class, 'update'])->middleware(['staff', 'can:edit_clients'])->name('clients.update'); diff --git a/tests/Feature/Clients/ClientInvitationTest.php b/tests/Feature/Clients/ClientInvitationTest.php new file mode 100644 index 00000000..2c952676 --- /dev/null +++ b/tests/Feature/Clients/ClientInvitationTest.php @@ -0,0 +1,240 @@ +admin = User::factory()->create(); +}); + +test('staff can send an invitation and it emails the invited address', function () { + Notification::fake(); + + $this->actingAs($this->admin)->post('/clients/invite', [ + 'email' => 'invited@example.com', + 'name' => 'Invited Person', + 'group_id' => 0, + ])->assertRedirect(route('clients.index')); + + $invitation = Invitation::query()->where('email', 'invited@example.com')->sole(); + expect($invitation->name)->toBe('Invited Person') + ->and($invitation->status)->toBe(Invitation::STATUS_PENDING) + ->and($invitation->invited_by_id)->toBe($this->admin->id) + ->and(ActivityLog::query()->where('action', Action::ClientInvited)->exists())->toBeTrue(); + + Notification::assertSentOnDemand( + ClientInvitationNotification::class, + fn (ClientInvitationNotification $n, array $channels, $notifiable): bool => $notifiable->routes['mail'] === 'invited@example.com', + ); +}); + +test('a storage quota set on the invitation carries through to the account it creates', function () { + app(Settings::class)->set(Setting::ClientsAutoApprove, true); + + // A string, deliberately: a real form field arrives as one, and + // $this->post() otherwise preserves whatever PHP type the test itself + // wrote — hiding exactly the mismatch a browser's actual POST would + // hit against a strictly-typed collaborator. + $this->actingAs($this->admin)->post('/clients/invite', [ + 'email' => 'invited@example.com', + 'group_id' => 0, + 'storage_quota_mb' => '500', + ]); + + $invitation = Invitation::query()->where('email', 'invited@example.com')->sole(); + expect($invitation->storage_quota_mb)->toBe(500); + + $this->post('/logout'); + + $this->post("/invite/{$invitation->token}", [ + 'token' => $invitation->token, + 'name' => 'Invited Person', + 'password' => 'super-secret-password', + 'password_confirmation' => 'super-secret-password', + ]); + + $client = User::query()->where('email', 'invited@example.com')->sole(); + expect($client->storage_quota_mb)->toBe(500); +}); + +test('leaving the storage quota blank inherits the site default, same as self-registration', function () { + $this->actingAs($this->admin)->post('/clients/invite', [ + 'email' => 'invited@example.com', + 'group_id' => 0, + ]); + + $invitation = Invitation::query()->where('email', 'invited@example.com')->sole(); + expect($invitation->storage_quota_mb)->toBe(0); +}); + +test('inviting an already-invited address supersedes the earlier invitation instead of leaving two live tokens', function () { + $first = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay()); + + $this->actingAs($this->admin)->post('/clients/invite', [ + 'email' => 'invited@example.com', + 'group_id' => 0, + ])->assertRedirect(route('clients.index')); + + expect($first->fresh()->status)->toBe(Invitation::STATUS_SUPERSEDED) + ->and(Invitation::query()->pending()->where('email', 'invited@example.com')->count())->toBe(1); + + $this->post('/logout'); + + $this->get("/invite/{$first->token}")->assertInertia( + fn (AssertableInertia $page) => $page->where('expired', true), + ); +}); + +test('an invitation cannot be sent to an address that already has an account', function () { + $existing = User::factory()->client()->create(['email' => 'taken@example.com']); + + $this->actingAs($this->admin)->post('/clients/invite', [ + 'email' => 'taken@example.com', + 'group_id' => 0, + ])->assertSessionHasErrors('email'); + + expect(Invitation::query()->where('email', 'taken@example.com')->exists())->toBeFalse(); + + $existing->delete(); +}); + +test('clients cannot send invitations', function () { + $this->actingAs(User::factory()->client()->create()); + + $this->get('/clients/invite')->assertRedirect(route('dashboard')); + $this->post('/clients/invite', ['email' => 'x@example.com', 'group_id' => 0])->assertForbidden(); +}); + +test('a valid invitation link shows the redemption form with the email locked', function () { + $invitation = Invitation::issue('invited@example.com', 'Invited Person', null, $this->admin, now()->addDay()); + + $this->get("/invite/{$invitation->token}")->assertInertia( + fn (AssertableInertia $page) => $page + ->component('auth/invite') + ->where('email', 'invited@example.com') + ->where('name', 'Invited Person') + ->where('expired', false), + ); +}); + +test('an unknown token reads as expired rather than a 404', function () { + $this->get('/invite/not-a-real-token')->assertInertia( + fn (AssertableInertia $page) => $page->where('expired', true), + ); +}); + +test('an expired invitation reads as expired', function () { + $invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->subMinute()); + + $this->get("/invite/{$invitation->token}")->assertInertia( + fn (AssertableInertia $page) => $page->where('expired', true), + ); +}); + +test('redeeming a valid invitation creates an active client and marks it redeemed, regardless of the self-registration auto-approve setting', function () { + app(Settings::class)->set(Setting::ClientsAutoApprove, false); + + $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(); + expect($client->type)->toBe(UserType::Client) + ->and($client->active)->toBeTrue() + ->and($client->account_requested)->toBeFalse() + ->and(ActivityLog::query()->where('action', Action::ClientInvitationRedeemed)->exists())->toBeTrue(); + + expect($invitation->fresh()->status)->toBe(Invitation::STATUS_REDEEMED); + + $this->post('/login', ['email' => 'invited@example.com', 'password' => 'super-secret-password']); + $this->assertAuthenticated(); +}); + +test('redeeming joins the group the invitation named', function () { + $group = Group::query()->create(['name' => 'Invited Clients']); + + $invitation = Invitation::issue('invited@example.com', null, $group, $this->admin, now()->addDay()); + + $this->post("/invite/{$invitation->token}", [ + 'token' => $invitation->token, + 'name' => 'Invited Person', + 'password' => 'super-secret-password', + 'password_confirmation' => 'super-secret-password', + ]); + + $client = User::query()->where('email', 'invited@example.com')->sole(); + expect($group->members()->where('users.id', $client->id)->exists())->toBeTrue(); +}); + +test('a redeemed invitation cannot be used again', function () { + $invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->addDay()); + $invitation->forceFill(['status' => Invitation::STATUS_REDEEMED])->save(); + + $this->post("/invite/{$invitation->token}", [ + 'token' => $invitation->token, + 'name' => 'Second Attempt', + 'password' => 'super-secret-password', + 'password_confirmation' => 'super-secret-password', + ])->assertSessionHasErrors('token'); + + expect(User::query()->where('email', 'invited@example.com')->exists())->toBeFalse(); +}); + +test('an expired invitation cannot be redeemed even by posting the right token', function () { + $invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->subMinute()); + + $this->post("/invite/{$invitation->token}", [ + 'token' => $invitation->token, + 'name' => 'Too Late', + 'password' => 'super-secret-password', + 'password_confirmation' => 'super-secret-password', + ])->assertSessionHasErrors('token'); + + expect(User::query()->where('email', 'invited@example.com')->exists())->toBeFalse(); +}); + +test('resending an expired invitation issues a fresh token and emails it, without exposing whether the old one was real', function () { + Notification::fake(); + + $invitation = Invitation::issue('invited@example.com', null, null, $this->admin, now()->subMinute()); + + $this->post("/invite/{$invitation->token}/resend")->assertRedirect(); + + $fresh = Invitation::query()->pending()->where('email', 'invited@example.com')->sole(); + expect($fresh->token)->not->toBe($invitation->token) + ->and($fresh->isExpired())->toBeFalse() + ->and($invitation->fresh()->status)->toBe(Invitation::STATUS_SUPERSEDED); + + // The spent link is retired along with the expired one: resending + // does not leave two working tokens for the same address. + $this->get("/invite/{$invitation->token}")->assertInertia( + fn (AssertableInertia $page) => $page->where('expired', true), + ); + + Notification::assertSentOnDemand( + ClientInvitationNotification::class, + fn (ClientInvitationNotification $n, array $channels, $notifiable): bool => $notifiable->routes['mail'] === 'invited@example.com', + ); + + // A token that was never real answers exactly the same way — no + // notification, but also no error revealing that. + Notification::fake(); + $this->post('/invite/not-a-real-token/resend')->assertRedirect(); + Notification::assertNothingSent(); +}); diff --git a/tests/Feature/Clients/ClientsManagementTest.php b/tests/Feature/Clients/ClientsManagementTest.php index 1b5f4a66..72618b63 100644 --- a/tests/Feature/Clients/ClientsManagementTest.php +++ b/tests/Feature/Clients/ClientsManagementTest.php @@ -153,6 +153,7 @@ test('staff can update client settings and they take effect', function () { 'clients_auto_group' => 0, 'clients_can_select_group' => 'none', 'clients_membership_deny_cooldown_days' => 30, + 'client_invitation_expiry_hours' => 72, 'default_client_storage_quota_mb' => 0, 'clients_can_preview_files' => true, ])->assertRedirect()->assertSessionDoesntHaveErrors(); diff --git a/tests/Feature/Groups/MembershipRequestsTest.php b/tests/Feature/Groups/MembershipRequestsTest.php index 7b3c41fb..f0ba20d4 100644 --- a/tests/Feature/Groups/MembershipRequestsTest.php +++ b/tests/Feature/Groups/MembershipRequestsTest.php @@ -179,6 +179,7 @@ test('the client settings screen validates the group options', function () { 'clients_auto_group' => $group->id, 'clients_can_select_group' => 'public', 'clients_membership_deny_cooldown_days' => 0, + 'client_invitation_expiry_hours' => 72, 'default_client_storage_quota_mb' => 0, 'clients_can_preview_files' => true, ])->assertSessionDoesntHaveErrors(); diff --git a/tests/Feature/Platform/EmailThemingTest.php b/tests/Feature/Platform/EmailThemingTest.php index 80f3c90f..c8cf7506 100644 --- a/tests/Feature/Platform/EmailThemingTest.php +++ b/tests/Feature/Platform/EmailThemingTest.php @@ -111,3 +111,20 @@ test('a listener that hides attribution strips the line from outgoing mail', fun expect($html)->not->toContain('Powered by ProjectSend') ->and($html)->toContain('All rights reserved'); }); + +test('the site name replaces the app name in the header and the salutation', function () { + // The default "Powered by ProjectSend" attribution line stays either + // way — this is about the header logo's alt text and the "Regards," + // signature, not that fixed string, so it is disabled here to keep + // the assertion about one thing. + Event::listen(ResolvingAttribution::class, function (ResolvingAttribution $event): void { + $event->visible = false; + }); + + app(Settings::class)->set(Setting::SiteName, 'Renamed Installation'); + + $html = renderThemedNotificationHtml(); + + expect($html)->toContain('Renamed Installation') + ->and($html)->not->toContain('ProjectSend'); +});