feat: add invite notification emails, functionality to admin dashboard and sign up page

Signed-off-by: Miguel Nogueira <me@nogueira.codes>
This commit is contained in:
2025-08-07 18:46:34 +01:00
parent 22cffaffca
commit f7c62a4ac2
19 changed files with 1141 additions and 2 deletions

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\InvitationRequest;
use App\Invitation;
use App\Mail\InviteApprovedMail;
use App\Mail\InvitedToApp;
use App\Mail\InviteRequestReceived;
use App\Response;
use Auth;
use Illuminate\Http\Request;
use Mail;
use Session;
class InvitationController extends Controller
{
public function index()
{
return view('dashboard.administration.invites', [
'invites' => Invitation::all()
]);
}
public function requestInvite(InvitationRequest $request)
{
$guest = Auth::guest();
$invitation = new Invitation();
$invitation->requestor_email = $request->input('email');
$invitation->requestor_ip_address = $request->ip();
$invitation->status = $guest ? 'pending' : 'approved';
$invitation->notified = !$guest; // confirmation msg doesn't count
$invitation->invitation_code = bin2hex(random_bytes(64));
$invitation->expiration = now()->addDays(2);
try {
$invitation->saveOrFail();
$addlMessage = ($guest) ? __('Check your email address for a confirmation email.') : '';
$request->session()->flash('success', __('Invitation request sent. :additionalUnauthenticatedMessage', ['additionalUnauthenticatedMessage' => $addlMessage]));
if ($guest) {
Mail::to($invitation->requestor_email)->send(new InviteRequestReceived());
}
else {
// this is an approved invite
Mail::to($invitation->requestor_email)->send(new InvitedToApp($invitation));
}
} catch (\Exception $exception) {
\Log::debug('[INVITES]: Error saving invite request', ['message' => $exception->getMessage(), 'requestor_ip' => $request->ip()]);
$request->session()->flash('error', __('Sorry, but we were unable to request an invitation for you. If you already requested one, trying to request another will not be possible, nor will it speed up the process.'));
}
return redirect()->back();
}
public function approveInvite(Request $request, Invitation $invitation)
{
$approvableStates = [
'pending'
];
if ($invitation->expiration && now()->lessThanOrEqualTo($invitation->expiration) && in_array($invitation->status, $approvableStates))
{
$invitation->status = 'approved';
$invitation->notified = true;
$invitation->save();
Mail::to($invitation->requestor_email)->send(new InviteApprovedMail($invitation));
return redirect()
->back()
->with('success', __('Invite request approved! This user can now sign up.'));
}
else
{
return redirect()
->back()
->with('error', __('This invitation couldn\'t be approved because either it\'s already approved or it is expired.'));
}
}
public function denyInvite(Request $request, Invitation $invitation)
{
$declinableStates = [
'pending'
];
if ($invitation->expiration && now()->lessThanOrEqualTo($invitation->expiration) && in_array($invitation->status, $declinableStates))
{
$invitation->status = 'denied';
$invitation->save();
return redirect()
->with('success', __('Invitation denied. No notifications were sent. This user cannot be invited again.'))
->back();
}
return redirect()
->with('error', __('This invitation could not be denied because it is either already approved, expired, or in an otherwise invalid state.'));
}
public function redeemInvite(Request $request)
{
return view('auth.redeem-invite', ['validationToken' => $request->route('token')]);
}
public function validateInvite(Request $request)
{
$token = $request->input('validation_token');
$email = $request->input('email');
$invite = Invitation::where('requestor_email', $email)->first();
if (!empty($invite) && $token === $invite->invitation_code && 'approved' === $invite->status && $invite->expiration && now()->lessThanOrEqualTo($invite->expiration))
{
$invite->status = 'completed';
$invite->save();
Session::put('ALLOW_REGISTRATION_OVERRIDE', true);
Session::put('REGISTRATION_OVERRIDE_EMAIL', $email);
return redirect()
->route('register')
->with('success', __('Invitation code validated! You can now sign up with the email address you were invited with.'));
}
else
{
return redirect()
->back()
->with('error', __('Something went wrong while validating your invite. Either it does not exist, is expired, has not been approved yet, or the token is wrong (do not edit it).'));
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Facades\Options;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Http\FormRequest;
class InvitationRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:254'],
];
}
public function authorize(): bool
{
if (Options::getOption('enable_registrations')) {
return false;
}
return true;
}
protected function failedAuthorization()
{
throw new AuthorizationException(__('You cannot request a new invite for this user/e-mail address right now. Keep in mind that users can only be invited once.'));
}
}

18
app/Invitation.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App;
use App\Policies\InvitationPolicy;
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
use Illuminate\Database\Eloquent\Model;
#[UsePolicy(InvitationPolicy::class)]
class Invitation extends Model
{
protected function casts(): array
{
return [
'expiration' => 'datetime'
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Mail;
use App\Invitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Queue\SerializesModels;
class InviteApprovedMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Invitation $invitation
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your invite for ' . config('app.name') . 'has just been approved',
);
}
public function content(): Content
{
return new Content(
view: 'mail.invite-approved',
with: [
'token' => $this->invitation->invitation_code,
'invitedDaysSince' => $this->invitation->created_at->diffInDays(now())
]
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class InviteRequestReceived extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct()
{
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your invite request has been received',
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'mail.invited-request-received',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

57
app/Mail/InvitedToApp.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
namespace App\Mail;
use App\Invitation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class InvitedToApp extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(public Invitation $invitation)
{
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'You\'ve just been invited to ' . config('app.name'),
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'mail.invited-to-app',
with: [
'token' => $this->invitation->invitation_code
]
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Policies;
use App\Invitation;
use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
class InvitationPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
}
public function view(User $user, Invitation $invitation): Response
{
return $user->can('admin.manageInvitations') ? Response::allow() : Response::deny(__('You do not have permission to view invitations.'));
}
public function create(?User $user): Response
{
if (is_null($user)) {
return Response::allow();
}
return $user->can('admin.manageInvitations') ? Response::allow() : Response::deny(__('You do not have permission to request invitations.'));
}
public function delete(User $user, Invitation $invitation): Response
{
return $user->can('admin.manageInvitations') ? Response::allow() : Response::deny(__('You do not have permission to revoke invitations.'));
}
}

View File

@@ -26,11 +26,13 @@ use App\Application;
use App\Appointment;
use App\Ban;
use App\Form;
use App\Invitation;
use App\Policies\AbsencePolicy;
use App\Policies\ApplicationPolicy;
use App\Policies\AppointmentPolicy;
use App\Policies\BanPolicy;
use App\Policies\FormPolicy;
use App\Policies\InvitationPolicy;
use App\Policies\ProfilePolicy;
use App\Policies\TeamFilePolicy;
use App\Policies\TeamPolicy;
@@ -68,6 +70,7 @@ class AuthServiceProvider extends ServiceProvider
Team::class => TeamPolicy::class,
TeamFile::class => TeamFilePolicy::class,
Absence::class => AbsencePolicy::class,
Invitation::class => InvitationPolicy::class,
];
/**
@@ -80,11 +83,11 @@ class AuthServiceProvider extends ServiceProvider
VerifyEmail::toMailUsing(function ($notifiable, $url) {
return (new MailMessage)
->greeting("Hi {$notifiable->name}! Welcome to ".config('app.name').'.')
->greeting("Hi {$notifiable->name}! Welcome to " . config('app.name') . '.')
->line('To finish setting up your account, you must verify your email. This is to ensure only real users access our website.')
->line('If you didn\'t sign up for an account, you can safely ignore this email.')
->action('Verify account', $url)
->salutation('The team at '.config('app.name'));
->salutation('The team at ' . config('app.name'));
});
Gate::define('viewLogViewer', function (?User $user) {