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'); +});