Making Laravel Jetstream Team Invitations Better
Jetstream is a powerful package for Laravel that allows you to create a Team-based experience (Jetstream Teams) for your new web application. I've used it in several projects and the latest version 2.x has added a number of improvements that I appreciate.
The Problem With Team Invitations
One of my frustrations with the package however, is the flow for inviting a new Team Member. Out of the box, this is how Team Invitations happen with Jetstream:
- As a Team Owner, you navigate to your team settings page which is by default located at /teams/{teamId}.
- At the bottom of the settings page are Team Member sections:
- Add Team Member (to add new Team Members)
- Pending Team Invitations (if there are any)
- Team Members (if there are any)
- From the Add Team Member section you can add the email and role of the new Team member you want to invite and click Add.
- The potential Team Member then receives an email invitation that gives them 2 options (depending on your Fortify registration features) to either:
- Create a new account first if they are not registered on the website, then come back to the invitation email and click "Accept Invitation".
- If they already have an account, click "Accept Invitation"
The Team Settings Members View
The first problem comes from the fact that the out-of-the-box solution Jetstream Teams provides is that new users have to accept the invitation in 2 steps, where really it should be a simple 1 step process. Technically, if you have email verification turned on as well, it would make it a 3 step process since the user would have to also verify their email again after clicking the first time to create the new account. You can see how this is stacking up to be much more complicated than necessary.
The Original Jetstream Team Invitation Email
The second problem is that when the user clicks one of the two options in the team invitation email, there is no indication that they are signing up for or logging into a team. It's just a normal login/register view. Ideally we would show the user that they are on track to sign up for the team the clicked the invitation for to avoid confusion.
Adding a simple heading above the login card component that states which team they are joining and the option to register instead of log in is a great first step. Let's see how we can implement this and other improvements.
Improving The Team Invitation Flow
This solution will be making a few assumptions: first, this is a new Laravel 9.x project, and we are using Laravel Sail to develop locally. Second, we're going to publishing the vender views for Teams with Livewire, utilizing the blade components provided by the vendor.
Follow the instructions to start up a new project with sail, install Jetstream with Livewire and Teams, then publish the Livewire vendor views.
Once you've done that, grab your coffee or drink of choice and lets dive into how the Teams Invitation process works.
How Team Invitations Work
After you have sent an invite to your team, the invited person receives an email. When a user clicks the "Accept Invitation" button from the email, they are following a "signed" route to the specific invitation handled by Jetstream's TeamInvitationController
like:
/team-invitations/{invitation}?signature=XXXXXXXXXXX
You can also see this route by running the artisan command:
sail artisan route:list
Signed routes are basically publicly accessible URLs that have a hashed signature parameter attached to them so that we can insure the URL has not been manipulated since it was created (you can read more about signed URLs here). The route for the team invitations endpoint is not published in the normal routes/web.php location, and instead registered from the venders routes, either livewire.php
or inertia.php
.
vendor/laravel/jetstream/routes/livewire.php
Looking at the route you can see that it is handled by the TeamInvitationController
accept()
method. Here's an overview of the livewire.php
file and how it's routing to the controller:
livewire.php
<?php
use Illuminate\Support\Facades\Route;
use Laravel\Jetstream\Http\Controllers\CurrentTeamController;
use Laravel\Jetstream\Http\Controllers\Livewire\ApiTokenController;
use Laravel\Jetstream\Http\Controllers\Livewire\PrivacyPolicyController;
use Laravel\Jetstream\Http\Controllers\Livewire\TeamController;
use Laravel\Jetstream\Http\Controllers\Livewire\TermsOfServiceController;
use Laravel\Jetstream\Http\Controllers\Livewire\UserProfileController;
use Laravel\Jetstream\Http\Controllers\TeamInvitationController;
use Laravel\Jetstream\Jetstream;
Route::group(['middleware' => config('jetstream.middleware', ['web'])], function () {
if (Jetstream::hasTermsAndPrivacyPolicyFeature()) {
Route::get('/terms-of-service', [TermsOfServiceController::class, 'show'])->name('terms.show');
Route::get('/privacy-policy', [PrivacyPolicyController::class, 'show'])->name('policy.show');
}
$authMiddleware = config('jetstream.guard')
? 'auth:'.config('jetstream.guard')
: 'auth';
$authSessionMiddleware = config('jetstream.auth_session', false)
? config('jetstream.auth_session')
: null;
Route::group(['middleware' => array_values(array_filter([$authMiddleware, $authSessionMiddleware, 'verified']))], function () {
// User & Profile...
Route::get('/user/profile', [UserProfileController::class, 'show'])
->name('profile.show');
// API...
if (Jetstream::hasApiFeatures()) {
Route::get('/user/api-tokens', [ApiTokenController::class, 'index'])->name('api-tokens.index');
}
// Teams...
if (Jetstream::hasTeamFeatures()) {
Route::get('/teams/create', [TeamController::class, 'create'])->name('teams.create');
Route::get('/teams/{team}', [TeamController::class, 'show'])->name('teams.show');
Route::put('/current-team', [CurrentTeamController::class, 'update'])->name('current-team.update');
Route::get('/team-invitations/{invitation}', [TeamInvitationController::class, 'accept'])
->middleware(['signed'])
->name('team-invitations.accept');
}
});
});
The middleware protecting the route requires both a valid signature and the user to be logged in. So basically, any guest will be redirected to the login page when visiting this signed route. However, it will be added to session storage as a session('url.intended')
entry, which the user will be redirected to once they do log in. The caveat is however, if the user is a new user and needs to register first, the 'url.intended'
is not followed after registration and the user is redirected back to the default logged in page (which is /dashboard
on new projects). The user would then have to go back to the email and click the "Accept Invitation" button again, which is just a bad experience, and also could introduce a loss of overall user retention.
Implementing The Log In Flow Improvements
Let's start by addressing the second issue mentioned, since it will tie into improving the first one as well. Basically, we just want the user to know that they have clicked on the correct link from the email and that completing the next action, whether signing in or signing up for a new account, will allow them to join the Team. Here's how the solution can look without redesigning much of the built in blade views:
Improved Team Invitation Login Page
We can tell if the guest user is trying to accept an invite by the route they are trying to access. Since it requires them to be logged in first, when can check via Middleware if the team invitation route was trying to be reached, and whether that invitation is still valid. The Request class includes a handy method called routeIs()
that we can utilize. Locate the app/Http/Middleware/Authenticate.php
file and replace the default code with this:
Authenticate.php
<?php
namespace App\Http\Middleware;
use App\Models\TeamInvitation;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*
* @param Request $request
* @return string|null
*/
protected function redirectTo($request)
{
// Before we forward to login page, see if this is a valid team invite
// and use info to show to User.
if ($request->hasValidSignature() && $request->routeIs('team-invitations.accept')) {
$invitationId = $request->route('invitation');
/** @var TeamInvitation $teamInvitation */
$teamInvitation = TeamInvitation::query()->find($invitationId);
$teamName = $teamInvitation->team->name ?? null;
// We should store session value as well, so we can prevent email confirmation
// since they already responded to an TeamInvitation.
if ($teamName) {
$request->session()->put('teamInvitation', $teamName);
} else {
/**
* If the invitation is deleted (already fulfilled), remove the
* intended URL to team invitation route so the User does not get
* a 403 after login or register. We can't do this here since
* the intended URL is not yet set, but place a marker to do so in.
* @see RedirectIfAuthenticated::handle()
*/
$request->session()->put('removeUrlIntended', true);
$request->session()->flash('status', "This invitation has expired.");
}
}
if (! $request->expectsJson()) {
return route('login');
}
}
}
Here we are making sure the signature for the team invitation route is valid, and then retrieving the Team the are trying to join. Now we can set a session key called teamInvitation
that has the name of the Team
. In the case where the invitation is no longer found, that means it as already been accepted, and we should not forward the logged in user to the invitation route since they would receive a 403 or 404. We can also send a status flash message so that they can see that the invitation has expired. Let's add this to both the resources/views/auth/login.blade.php
and resources/views/auth/register.blade.php
views.
login.blade.php
<x-guest-layout>
<x-jet-authentication-card>
@if(session('teamInvitation'))
<x-slot name="header">
<h4>
Log in or <a class="underline hover:text-gray-900" href="{{ route('register') }}">register</a>
to join <strong class="text-blue-800">{{ session('teamInvitation') }}</strong>.
</h4>
</x-slot>
@endif
<x-slot name="logo">
<x-jet-authentication-card-logo />
</x-slot>
<x-jet-validation-errors class="mb-4" />
@if (session('status'))
<div class="mb-4 font-medium text-sm text-green-600">
{{ session('status') }}
</div>
@endif
<form method="POST" action="{{ route('login') }}">
@csrf
<div>
<x-jet-label for="email" value="{{ __('Email') }}" />
<x-jet-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
</div>
<div class="mt-4">
<x-jet-label for="password" value="{{ __('Password') }}" />
<x-jet-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" />
</div>
<div class="block mt-4 flex items-center justify-between">
<label for="remember_me" class="flex items-center">
<x-jet-checkbox id="remember_me" name="remember" />
<span class="ml-2 text-sm text-gray-600">{{ __('Remember me') }}</span>
</label>
<x-jet-button class="ml-4">
{{ __('Log in') }}
</x-jet-button>
</div>
<div class="flex items-center justify-center mt-4">
@if (Route::has('password.request'))
<a class="underline text-sm text-gray-600 hover:text-gray-900" href="{{ route('password.request') }}">
{{ __('Forgot your password?') }}
</a>
@endif
</div>
<div class="block mt-4 text-center">
<span class="ml-2 text-sm text-gray-600">{{ __('New around here?') }}</span>
<a class="underline hover:text-gray-900" href="{{ route('register') }}">Sign Up!</a>
</div>
</form>
</x-jet-authentication-card>
</x-guest-layout>
register.blade.php
<x-guest-layout>
<x-jet-authentication-card>
<x-slot name="logo">
<x-jet-authentication-card-logo />
</x-slot>
@if(session('teamInvitation'))
<x-slot name="header">
<h4>
Register or <a class="underline hover:text-gray-900" href="{{ route('login') }}">log in</a>
to join <strong class="text-blue-800">{{ session('teamInvitation') }}</strong>.
</h4>
</x-slot>
@endif
<x-jet-validation-errors class="mb-4" />
<form method="POST" action="{{ route('register') }}">
@csrf
<div>
<x-jet-label for="name" value="{{ __('Name') }}" />
<x-jet-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
</div>
<div class="mt-4">
<x-jet-label for="email" value="{{ __('Email') }}" />
<x-jet-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required />
</div>
<div class="mt-4">
<x-jet-label for="password" value="{{ __('Password') }}" />
<x-jet-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
</div>
<div class="mt-4">
<x-jet-label for="password_confirmation" value="{{ __('Confirm Password') }}" />
<x-jet-input id="password_confirmation" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
</div>
@if (Laravel\Jetstream\Jetstream::hasTermsAndPrivacyPolicyFeature())
<div class="mt-4">
<x-jet-label for="terms">
<div class="flex items-center">
<x-jet-checkbox name="terms" id="terms"/>
<div class="ml-2">
{!! __('I agree to the :terms_of_service and :privacy_policy', [
'terms_of_service' => '<a target="_blank" href="'.route('terms.show').'" class="underline text-sm text-gray-600 hover:text-gray-900">'.__('Terms of Service').'</a>',
'privacy_policy' => '<a target="_blank" href="'.route('policy.show').'" class="underline text-sm text-gray-600 hover:text-gray-900">'.__('Privacy Policy').'</a>',
]) !!}
</div>
</div>
</x-jet-label>
</div>
@endif
<div class="flex items-center justify-end mt-4">
<a class="underline text-sm text-gray-600 hover:text-gray-900" href="{{ route('login') }}">
{{ __('Already registered?') }}
</a>
<x-jet-button class="ml-4">
{{ __('Register') }}
</x-jet-button>
</div>
</form>
</x-jet-authentication-card>
</x-guest-layout>
Now the guest user can see from both the login view and register view that they are joining the intended team, great! Moving forward, since we set the session variable removeUrlIntended
to mark when a guest is trying to accept an invitation that no longer exists, we cannot remove the session('url.intended')
value until after it is actually set farther down the chain of middleware. So let's do this in the RedirectIfAuthenticated
file.
RedirectIfAuthenticated.php
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @param string|null ...$guards
*
* @return Response|RedirectResponse
*/
public function handle(Request $request, Closure $next, ...$guards): Response|RedirectResponse
{
$guards = empty($guards) ? [null] : $guards;
// Before we redirect to any intended url,
// check if an earlier request to remove it was made.
if ($request->session()->has('removeUrlIntended')) {
$request->session()->forget('url.intended');
$request->session()->forget('removeUrlIntended');
}
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}
Now let's make the email template only show one button to accept the invite and allow the auth middleware to handle the redirection to the login view we just updated. You can find it in resources/views/vendor/jetstream/mail/team-invitation.blade.php
. Replace the code with this content:
team-invitation.blade.php
@component('mail::message')
{{ __('You have been invited to join the :team team!', ['team' => $invitation->team->name]) }}
{{ __('You may accept this invitation by clicking the button below and logging in:') }}
@component('mail::button', ['url' => $acceptUrl])
{{ __('Accept Invitation') }}
@endcomponent
@if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration()))
{{ __('If you do not have an account yet, click the register link on the login page. After creating an account, you will be automatically added to the team.') }}
@endif
{{ __('If you did not expect to receive an invitation to this team, you may discard this email.') }}
@endcomponent
Another scenario you might be thinking of is what if the user is logged into a different account that wasn't invited and then clicks the team invitation? Well thankfully we are already covered by Teams with the app/Actions/Jetstream/AddTeamMember.php
Action. In that class, which is made available for us to update if need be, there is validation done to make sure invited user is the user trying to join, as well as other Gates and validation.
Improving Invitation Acceptance Flow For New Users
Next, let's cover the new user scenario by modifying the actions that take place upon registration. With Jetstream, we are using the authentication package Fortify which makes available several Actions for us. Lets add this to the app/Actions/Fortify/CreateNewUser.php
file:
CreateNewUser.php
<?php
namespace App\Actions\Fortify;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Create a newly registered user.
*
* @param array $input
*
* @return User
*/
public function create(array $input)
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
])->validate();
return DB::transaction(function () use ($input) {
return tap(User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'email_verified_at' => session('teamInvitation') ? now() : null,
]), function (User $user) {
$this->createTeam($user);
});
});
}
/**
* Create a personal team for the user.
*
* @param User $user
*
* @return void
*/
protected function createTeam(User $user): void
{
$user->ownedTeams()->save(Team::forceCreate([
'user_id' => $user->id,
'name' => explode(' ', $user->name, 2)[0]."'s Team",
'personal_team' => true,
]));
}
}
The main thing to note here is that we are adding in the email_verified_at
value to the newly created user with a now()
date if they have the teamInvitation
session value set. This is so we don't interrupt the url.intended
redirect that should happen if they are registering to accept the invite. The user should also not have to confrim their email again since they just clicked the "Accept Invitation" button from the invite email. But to make this work successfully we need to make the email_verified_at
value fillable on the User model, and also change the redirect behavior after registration which by default does not follow any session('url.intended')
redirect after completion.
In the User
model, make sure these columns are set for $fillable
:
User.php
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'email',
'email_verified_at',
'name',
'password',
];
This next part is a little trickier because we need append some new Middleware to the authentication flow in the right place for the post-registration redirect to happen as we wish, without modifying vendor files. Luckily, Jeststream gives us a way to override the authentication middleware flow for Fortify and we can append a process to occur after the new user session is created. Lets start with updating the app/Providers/JetstreamServiceProvider.php
file:
JetstreamServiceProvider.php
<?php
namespace App\Providers;
use App\Actions\Fortify\UserLoggedIn;
use App\Actions\Jetstream\AddTeamMember;
use App\Actions\Jetstream\CreateTeam;
use App\Actions\Jetstream\DeleteTeam;
use App\Actions\Jetstream\DeleteUser;
use App\Actions\Jetstream\InviteTeamMember;
use App\Actions\Jetstream\RemoveTeamMember;
use App\Actions\Jetstream\UpdateTeamName;
use Laravel\Fortify\Actions\AttemptToAuthenticate;
use Laravel\Fortify\Actions\EnsureLoginIsNotThrottled;
use Laravel\Fortify\Actions\PrepareAuthenticatedSession;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\Fortify;
use Illuminate\Http\Request;
use Illuminate\Support\ServiceProvider;
use Laravel\Jetstream\Jetstream;
class JetstreamServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
$this->configurePermissions();
Jetstream::createTeamsUsing(CreateTeam::class);
Jetstream::updateTeamNamesUsing(UpdateTeamName::class);
Jetstream::addTeamMembersUsing(AddTeamMember::class);
Jetstream::inviteTeamMembersUsing(InviteTeamMember::class);
Jetstream::removeTeamMembersUsing(RemoveTeamMember::class);
Jetstream::deleteTeamsUsing(DeleteTeam::class);
Jetstream::deleteUsersUsing(DeleteUser::class);
Fortify::authenticateThrough(function (Request $request) {
return array_filter([
config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class,
RedirectIfTwoFactorAuthenticatable::class,
AttemptToAuthenticate::class,
PrepareAuthenticatedSession::class,
UserLoggedIn::class,
]);
});
}
/**
* Configure the roles and permissions that are available within the application.
*
* @return void
*/
protected function configurePermissions()
{
Jetstream::defaultApiTokenPermissions(['read']);
Jetstream::role('admin', 'Administrator', [
'create',
'read',
'update',
'delete',
])->description('Administrator users can perform any action.');
Jetstream::role('editor', 'Editor', [
'read',
'create',
'update',
])->description('Editor users have the ability to read, create, and update.');
}
}
The modification we are making here is adding in the Fortify::authenticateThrough()
call to the boot()
method with the new middleware UserLoggedIn
appended to the default list of classes Fortify uses. Let's also make that class file in the Fority Actions directory. Create the file app/Actions/Fortify/UserLoggedIn.php
with this content:
<?php
namespace App\Actions\Fortify;
use Illuminate\Http\Request;
class UserLoggedIn
{
/**
* Handle the incoming request.
*
* @param Request $request
* @param callable $next
*
* @return mixed
*/
public function handle(Request $request, callable $next)
{
// If a user registered, and they had a team invitation,
// do url intended redirect to team invitation route.
if (session()->has('teamInvitation') && session()->has('url.intended')) {
return redirect()->intended();
}
return $next($request);
}
}
Again we're reusing that handy session value to mark when we want invitation events to happen.
Tying It All Together
So far we've greatly improved the user flow for excepting team invitations from 2 or 3 actions down to 1. At this point this may be good enough, but there is another area of customization we may want to update, and that is the Banner notification the user receives when the invite was successfully accepted. This, unfortunately, is buried deep in the TeamInvitationController vendor file mentioned earlier. And to my knowledge, there is no way to configure it. That means we'll need to override the Controller by having the route point to our own version. We can do this in 2 steps.
First, we'll replicate the routing from Jetstreams livewire.php
route file. Add this to the end of your route/web.php
file:
web.php
// Override Jetstream Team Invitation route
Route::group(['middleware' => config('jetstream.middleware', ['web'])], function () {
$authMiddleware = config('jetstream.guard')
? 'auth:'.config('jetstream.guard')
: 'auth';
$authSessionMiddleware = config('jetstream.auth_session', false)
? config('jetstream.auth_session')
: null;
Route::group(['middleware' => array_values(array_filter([$authMiddleware, $authSessionMiddleware, 'verified']))], function () {
// Teams...
if (Jetstream::hasTeamFeatures()) {
Route::get('/team-invitations/{invitation}', [TeamInvitationController::class, 'accept'])
->middleware(['signed'])
->name('team-invitations.accept');
}
});
});
Create a new controller with the same name but in our Controllers directory by using the artisan command:
sail artisan make:controller TeamInvitationController
In that controller we only want to copy over the route method we are overriding, since the rest of the functionality can be safely handled by the vendor.
<?php
namespace App\Http\Controllers;
use App\Models\TeamInvitation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Jetstream\Contracts\AddsTeamMembers;
class TeamInvitationController extends Controller
{
/**
* Accept a team invitation.
*
* @param Request $request
* @param TeamInvitation $invitation
*
* @return RedirectResponse
*/
public function accept(Request $request, TeamInvitation $invitation)
{
app(AddsTeamMembers::class)->add(
$invitation->team->owner,
$invitation->team,
$invitation->email,
$invitation->role
);
$invitedTeam = $invitation->team;
// Since the user just accepted invite to this team, set that as the current.
Auth::user()->switchTeam($invitedTeam);
$invitation->delete();
if ($request->session()->has('teamInvitation')) {
$request->session()->forget('teamInvitation');
}
return redirect(config('fortify.home'))->banner(
__('Great! You have accepted the invitation to join :team.', ['team' => $invitation->team->name]),
);
}
}
And that's it! Now you can customize the banner as well for the new team members, completing a much more intuitive end-to-end flow for new team members.
One other bonus we are adding here is that we are setting the user's current Team to the one they just accepted the invite too via Auth::user()->switchTeam($invitedTeam);
. By default Jetstream sets the user's current Team to be their "personal team", which in my opinion does not make sense for the invitation flow. The topic of personal teams is another debated concept, and there are ways we can disable this all together, but that's a separate article for another time.
UX Upgrade Achieved
Whew! That was quite the journey. Thank you for following along. Hopefully you have a much better Jetstream Teams experience for your users. I'd love to hear your feedback on these code changes and if you'd have done something differently, or if you think something was missing from this development process. Either way, feel free to learn more about me and thanks for reading.