mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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<string, mixed> $context Placeholders for the action's
|
||||
* log template, e.g. which
|
||||
* provider an account came from.
|
||||
* @param int $storageQuotaMb 0 means no per-account quota and
|
||||
* inherits the site default at
|
||||
* enforcement time — see
|
||||
* ClientStorageUsage::quotaMb(). Same
|
||||
* meaning as ClientAccounts::create()'s
|
||||
* parameter of the same name; a caller
|
||||
* with no quota to offer (the public
|
||||
* registration form, LDAP) leaves it at 0.
|
||||
*/
|
||||
public function provision(
|
||||
string $name,
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Clients\Models\Invitation;
|
||||
use App\Modules\Clients\Notifications\ClientInvitationNotification;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Staff sending a client an invitation to register, ahead of the public
|
||||
* form — the "New client" button's sibling for an installation that
|
||||
* would rather have somebody set their own password than hand them one.
|
||||
*/
|
||||
class InvitationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly Settings $settings,
|
||||
private readonly ClientStorageUsage $storageUsage,
|
||||
) {}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('clients/invite', [
|
||||
'groups' => 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.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Clients\ClientProvisioning;
|
||||
use App\Modules\Clients\Models\Invitation;
|
||||
use App\Modules\Clients\Notifications\ClientInvitationNotification;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* A client redeeming the link an invitation emailed them — the invited
|
||||
* counterpart to RegistrationController's public form. Reaching the form
|
||||
* at all is the whole difference: it is gated by a specific address
|
||||
* having a live token rather than by Setting::ClientsCanRegister, and the
|
||||
* account that comes out of it is provisioned exactly the way any other
|
||||
* self-registration is (ClientProvisioning), so an installation with
|
||||
* auto-approve off still puts one in the same queue as everybody else.
|
||||
*/
|
||||
class InvitationRedemptionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClientProvisioning $provisioning,
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$token = (string) $request->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* A staff-sent invitation for a specific address to register a client
|
||||
* account, ahead of the public registration form. Redeeming one is
|
||||
* handled by ClientProvisioning, the same as any other self-provisioned
|
||||
* account — an invitation only settles who is allowed to reach the form
|
||||
* and with which address, not the account's own policy.
|
||||
*
|
||||
* **The token is the whole authorization**, the same as a file's share
|
||||
* link: the redemption route has nothing else to look it up by, so it is
|
||||
* stored the way CreateShareLink stores one — Str::random(40), plain,
|
||||
* queried directly — rather than hashed the way a password is.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string|null $name
|
||||
* @property string $email
|
||||
* @property string $token
|
||||
* @property string $status
|
||||
* @property int $storage_quota_mb
|
||||
* @property int|null $group_id
|
||||
* @property int|null $invited_by_id
|
||||
* @property Carbon $expires_at
|
||||
*/
|
||||
class Invitation extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_REDEEMED = 'redeemed';
|
||||
|
||||
// Retired by a fresh invitation to the same address, issued either
|
||||
// because staff sent another one or because the invited person asked
|
||||
// for a new link — see issue(). Never redeemable, but kept rather than
|
||||
// deleted so the activity log's trail of who invited this address,
|
||||
// and when, stays intact.
|
||||
public const STATUS_SUPERSEDED = 'superseded';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => '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<Invitation> $query
|
||||
* @return Builder<Invitation>
|
||||
*/
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Group, $this>
|
||||
*/
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients\Notifications;
|
||||
|
||||
use App\Modules\Platform\Notifications\Concerns\RendersOverridableMail;
|
||||
use App\Modules\Platform\Notifications\EmailTemplateSlot;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Sent on-demand (Notification::route('mail', ...)), never via
|
||||
* $client->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<int, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// A staff-sent link inviting a specific address to register, ahead
|
||||
// of the public form self-registration uses. The token is the
|
||||
// whole authorization and is queried directly, the same as a
|
||||
// file's share link — see Invitation's docblock.
|
||||
Schema::create('invitations', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -3530,6 +3530,8 @@
|
||||
"social.account_unlinked",
|
||||
"client.approved",
|
||||
"client.denied",
|
||||
"client.invited",
|
||||
"client.invitation_redeemed",
|
||||
"file.uploaded",
|
||||
"file.updated",
|
||||
"file.deleted",
|
||||
|
||||
+11
-1
@@ -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.
|
||||
|
||||
@@ -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<AcceptInvitationForm>({
|
||||
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 (
|
||||
<AuthLayout title={t('This invitation has expired')} description={t('Ask for a new one and it will arrive in a moment.')}>
|
||||
<Head title={t('This invitation has expired')} />
|
||||
|
||||
{status && <p className="text-muted-foreground mb-4 text-center text-sm">{status}</p>}
|
||||
|
||||
<form onSubmit={resend}>
|
||||
<Button type="submit" className="w-full" disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
{t('Send me a new invitation')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout title={t('Create your account')} description={t('Choose a name and password to finish setting up your account.')}>
|
||||
<Head title={t('Create your account')} />
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{t('Email address')}</Label>
|
||||
<Input id="email" type="email" value={email} readOnly className="mt-1 block w-full" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">{t('Name')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="name"
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">{t('Password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
/>
|
||||
<PasswordRequirements />
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">{t('Confirm password')}</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={data.password_confirmation}
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.password_confirmation} />
|
||||
</div>
|
||||
|
||||
<InputError message={errors.token} />
|
||||
|
||||
<Button type="submit" className="w-full" disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
{t('Create account')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,11 @@ export default function ClientsIndex({ clients, pagination, filters, reassign_ca
|
||||
<Link href={route('client-custom-fields.index')}>{t('Manage custom fields')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
{can('create_clients') && (
|
||||
<Button asChild>
|
||||
<Link href={route('invitations.create')}>{t('Invite client')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
{can('create_clients') && (
|
||||
<SeatLimitedAction
|
||||
seats={seats}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import Heading from '@/components/heading';
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
|
||||
interface InvitationFormData {
|
||||
[key: string]: string;
|
||||
email: string;
|
||||
name: string;
|
||||
group_id: string;
|
||||
storage_quota_mb: string;
|
||||
}
|
||||
|
||||
interface ClientsInviteProps {
|
||||
groups: { id: number; name: string }[];
|
||||
default_storage_quota_mb: number;
|
||||
}
|
||||
|
||||
export default function ClientsInvite({ groups, default_storage_quota_mb }: ClientsInviteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{ title: t('Clients'), href: '/clients' },
|
||||
{ title: t('Invite client'), href: '/clients/invite' },
|
||||
];
|
||||
|
||||
const { data, setData, post, processing, errors } = useForm<InvitationFormData>({
|
||||
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 (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title={t('Invite client')} />
|
||||
|
||||
<div className="px-4 py-6">
|
||||
<Heading title={t('Invite client')} description={t('Invite a client to share files with')} />
|
||||
|
||||
<form onSubmit={submit} className="grid max-w-md gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{t('Email address')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">{t('Name (optional)')}</Label>
|
||||
<Input id="name" value={data.name} onChange={(e) => setData('name', e.target.value)} autoComplete="off" />
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="group_id">{t('Group (optional)')}</Label>
|
||||
<Select value={data.group_id} onValueChange={(value) => setData('group_id', value)}>
|
||||
<SelectTrigger id="group_id" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t('None')}</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-sm">{t('The client joins this group as soon as they register.')}</p>
|
||||
<InputError message={errors.group_id} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="storage_quota_mb">{t('Storage quota (MB)')}</Label>
|
||||
<Input
|
||||
id="storage_quota_mb"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={String(default_storage_quota_mb)}
|
||||
value={data.storage_quota_mb}
|
||||
onChange={(e) => setData('storage_quota_mb', e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{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.')}
|
||||
</p>
|
||||
<InputError message={errors.storage_quota_mb} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{t('Send invitation')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<InputError message={errors.clients_membership_deny_cooldown_days} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="client_invitation_expiry_hours">{t('Invitation links expire after (hours)')}</Label>
|
||||
<Input
|
||||
id="client_invitation_expiry_hours"
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
className="w-32"
|
||||
value={data.client_invitation_expiry_hours}
|
||||
onChange={(e) => setData('client_invitation_expiry_hours', e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('How long a staff-sent invitation stays valid before the invited address has to ask for a new one.')}
|
||||
</p>
|
||||
<InputError message={errors.client_invitation_expiry_hours} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="default_client_storage_quota_mb">{t('Default storage quota (MB)')}</Label>
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{{-- Laravel's HTML message wrapper, published so the page title reads
|
||||
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 wrapper, re-copy it
|
||||
and re-apply the one-line change below. --}}
|
||||
<?php
|
||||
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
|
||||
$siteName = app(Settings::class)->get(Setting::SiteName);
|
||||
$siteName = is_string($siteName) ? $siteName : 'ProjectSend';
|
||||
?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<title>{{ $siteName }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<style>
|
||||
@media only screen and (max-width: 600px) {
|
||||
.inner-body {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.button {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{!! $head ?? '' !!}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{!! $header ?? '' !!}
|
||||
|
||||
<!-- Email Body -->
|
||||
<tr>
|
||||
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
|
||||
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Body content -->
|
||||
<tr>
|
||||
<td class="content-cell">
|
||||
{!! Illuminate\Mail\Markdown::parse($slot) !!}
|
||||
|
||||
{!! $subcopy ?? '' !!}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{!! $footer ?? '' !!}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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. --}}
|
||||
<?php
|
||||
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
|
||||
$siteName = app(Settings::class)->get(Setting::SiteName);
|
||||
$siteName = is_string($siteName) ? $siteName : 'ProjectSend';
|
||||
?>
|
||||
<x-mail::layout>
|
||||
{{-- Header --}}
|
||||
<x-slot:header>
|
||||
<x-mail::header :url="config('app.url')">
|
||||
{{ $siteName }}
|
||||
</x-mail::header>
|
||||
</x-slot:header>
|
||||
|
||||
{{-- Body --}}
|
||||
{!! $slot !!}
|
||||
|
||||
{{-- Subcopy --}}
|
||||
@isset($subcopy)
|
||||
<x-slot:subcopy>
|
||||
<x-mail::subcopy>
|
||||
{!! $subcopy !!}
|
||||
</x-mail::subcopy>
|
||||
</x-slot:subcopy>
|
||||
@endisset
|
||||
|
||||
{{-- Footer --}}
|
||||
<x-slot:footer>
|
||||
<x-mail::footer>
|
||||
© {{ date('Y') }} {{ $siteName }}. {{ __('All rights reserved.') }}
|
||||
</x-mail::footer>
|
||||
</x-slot:footer>
|
||||
</x-mail::layout>
|
||||
@@ -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. --}}
|
||||
<?php
|
||||
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
|
||||
$siteName = app(Settings::class)->get(Setting::SiteName);
|
||||
$siteName = is_string($siteName) ? $siteName : 'ProjectSend';
|
||||
?>
|
||||
<x-mail::layout>
|
||||
{{-- Header --}}
|
||||
<x-slot:header>
|
||||
<x-mail::header :url="config('app.url')">
|
||||
{{ $siteName }}
|
||||
</x-mail::header>
|
||||
</x-slot:header>
|
||||
|
||||
{{-- Body --}}
|
||||
{{ $slot }}
|
||||
|
||||
{{-- Subcopy --}}
|
||||
@isset($subcopy)
|
||||
<x-slot:subcopy>
|
||||
<x-mail::subcopy>
|
||||
{{ $subcopy }}
|
||||
</x-mail::subcopy>
|
||||
</x-slot:subcopy>
|
||||
@endisset
|
||||
|
||||
{{-- Footer --}}
|
||||
<x-slot:footer>
|
||||
<x-mail::footer>
|
||||
© {{ date('Y') }} {{ $siteName }}. @lang('All rights reserved.')
|
||||
</x-mail::footer>
|
||||
</x-slot:footer>
|
||||
</x-mail::layout>
|
||||
+21
-11
@@ -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. --}}
|
||||
<x-mail::message>
|
||||
{{-- Greeting --}}
|
||||
@if (! empty($greeting))
|
||||
@@ -53,8 +59,12 @@
|
||||
@if (! empty($salutation))
|
||||
{{ $salutation }}
|
||||
@else
|
||||
<?php
|
||||
$siteName = app(\App\Modules\Platform\Settings\Settings::class)->get(\App\Modules\Platform\Settings\Setting::SiteName);
|
||||
$siteName = is_string($siteName) ? $siteName : 'ProjectSend';
|
||||
?>
|
||||
@lang('Regards,')<br>
|
||||
{{ config('app.name') }}
|
||||
{{ $siteName }}
|
||||
@endif
|
||||
|
||||
{{-- Subcopy --}}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Clients\Models\Invitation;
|
||||
use App\Modules\Clients\Notifications\ClientInvitationNotification;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user