Let's finish the invitation system by accepting the invitation.
If I'm logged in to another tenant at the moment, I need to:
- Accept the invitation.
- Attach me to the tenant.
- Set the current tenant from the invitation.
- Redirect to the invited tenants' dashboard.
app/Http/Controllers/UserController.php:
class UserController extends Controller{ // ... public function acceptInvitation(string $token) { $invitation = Invitation::where('token', $token) ->whereNull('accepted_at') ->firstOrFail(); if (auth()->check()) { $invitation->update(['accepted_at' => now()]); auth()->user()->tenants()->attach($invitation->tenant_id); auth()->user()->update(['current_tenant_id' => $invitation->tenant_id]); $tenantDomain = str_replace('://', '://' . $invitation->tenant->subdomain . '.', config('app.url')); return redirect($tenantDomain . route('dashboard', absolute: false)); } else { // redirect to register } }}
Now, if I register with a new user, this user has only its own tenant.
From another user, I send email invitations to the newly registered user.
As you can see, when the invitation is accepted, the user should be redirected to the bbb
subdomain tenant and should have two tenants.
We can see that after accepting the invitation, the user is redirected to the correct subdomain. The user now belongs to two tenants. The tenant in bold is active, which is correct in this case.
Good. The first case is working correctly. Now, let's cover the case of registering a fresh user after the invitation.
In the UserController
, we only need to redirect the user to the registration page with a token in the URL.
app/Http/Controllers/UserController.php:
class UserController extends Controller{ // ... public function acceptInvitation(string $token): RedirectResponse { $invitation = Invitation::where('token', $token) ->whereNull('accepted_at') ->firstOrFail(); if (auth()->check()) { $invitation->update(['accepted_at' => now()]); auth()->user()->tenants()->attach($invitation->tenant_id); auth()->user()->update(['current_tenant_id' => $invitation->tenant_id]); $tenantDomain = str_replace('://', '://' . $invitation->tenant->subdomain . '.', config('app.url')); return redirect($tenantDomain . route('dashboard', absolute: false)); } else { return redirect()->route('register', ['token' => $invitation->token]); } }}
Now, we will pass the email to the register form. We must get that email from the invitation token.
app/Http/Controllers/Auth/RegisteredUserController.php:
use App\Models\Invitation; class RegisteredUserController extends Controller{ public function create(): View { $invitationEmail = null; if (request('token')) { $invitation = Invitation::where('token', request('token')) ->whereNull('accepted_at') ->firstOrFail(); $invitationEmail = $invitation->email; } return view('auth.register'); return view('auth.register', compact('invitationEmail')); } // ...}
We don't need the subdomain in the View because it will already exist. Also, we will disable the email field and set the value to $invitationEmail
.
resources/views/auth/register.blade.php:
// ... <!-- Email Address --><div class="mt-4"> <x-input-label for="email" :value="__('Email')" /> <x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" :disabled="! is_null($invitationEmail)" /> <x-input-error :messages="$errors->get('email')" class="mt-2" /></div> <!-- Subdomain -->@empty($invitationEmail) <div class="mt-4"> <x-input-label for="subdomain" :value="__('Subdomain')" /> <x-text-input id="subdomain" class="block mt-1 mr-2 w-full" type="text" name="subdomain" :value="old('subdomain')" required /> <x-input-error :messages="$errors->get('subdomain')" class="mt-2" /> </div>@endempty // ...
We also need the token as a hidden input.
resources/views/auth/register.blade.php:
// ... @csrf@empty(! $invitationEmail) <input type="hidden" value="{{ request('token') }}">@endempty // ...
If we try to invite with an email that isn't registered already after opening the invitation URL from the email, we are redirected to the tenant's registration page. We don't see the subdomain input in the registration page, and email is disabled.
Finally, we need to register the new user. First, we must change the validation rule from required
to sometimes
for the email
and subdomain
inputs.
app/Http/Controllers/Auth/RegisteredUserController.php:
class RegisteredUserController extends Controller{ // ... public function store(Request $request): RedirectResponse { $request->validate([ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], 'email' => ['sometimes', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], 'password' => ['required', 'confirmed', Rules\Password::defaults()], 'subdomain' => ['required', 'alpha:ascii', 'unique:'.Tenant::class], 'subdomain' => ['sometimes', 'alpha:ascii', 'unique:'.Tenant::class], ]); // ... }}
Next, we must check the token, and if it's invalid, throw a validation error.
app/Http/Controllers/Auth/RegisteredUserController.php:
class RegisteredUserController extends Controller{ // ... public function store(Request $request): RedirectResponse { $request->validate([ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], 'email' => ['sometimes', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], 'password' => ['required', 'confirmed', Rules\Password::defaults()], 'subdomain' => ['required', 'alpha:ascii', 'unique:'.Tenant::class], 'subdomain' => ['sometimes', 'alpha:ascii', 'unique:'.Tenant::class], ]); $email = $request->email; if ($request->has('token')) { $invitation = Invitation::with('tenant') ->where('token', $request->token) ->whereNull('accepted_at') ->firstOr(function () { throw ValidationException::withMessages([ 'email' => __('Invitation link incorrect'), ]); }); $email = $invitation->email; } $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'email' => $email, 'password' => Hash::make($request->password), ]); // ... }}
Then, if we have an invitation, the user should be attached to the existing one instead of creating a tenant. Finally, it is redirected to the correct subdomain.
app/Http/Controllers/Auth/RegisteredUserController.php:
class RegisteredUserController extends Controller{ // ... public function store(Request $request): RedirectResponse { // ... $user = User::create([ 'name' => $request->name, 'email' => $email, 'password' => Hash::make($request->password), ]); $subdomain = $request->subdomain; if ($invitation) { $invitation->update(['accepted_at' => now()]); $invitation->tenant->users()->attach($user->id); $user->update(['current_tenant_id' => $invitation->tenant_id]); $subdomain = $invitation->tenant->subdomain; } else { $tenant = Tenant::create([ 'name' => $request->name . ' Team', 'subdomain' => $request->subdomain, ]); $tenant->users()->attach($user, ['is_owner' => true]); $user->update(['current_tenant_id' => $tenant->id]); } event(new Registered($user)); Auth::login($user); $tenantDomain = str_replace('://', '://' . $request->subdomain . '.', config('app.url')); $tenantDomain = str_replace('://', '://' . $subdomain . '.', config('app.url')); return redirect($tenantDomain . route('dashboard', absolute: false)); }}
The new user's registration from the invitation link should now work. Now, we have the entire process of seeing the invited users, inviting a new user, and then accepting the invitation as an existing or new user.
We have finished the chapter on single database multi-tenancy without any packages.
In the following sections, we will repeat almost the same things with extra packages and see how they help us.
hi would you be able to do a video on how to handle the invitation system for using archtechx tenancy package with multiple dbs for tenants while this works fine for single db for multiple dbs i think the invitations and users should be stored in tenants db , and have a separate login via tenant.domain.com/login thanks
Hi Vasile, To be honest, I haven't tried it. Currently I don't have time available to explore this topic, but I will add it to my (already huge) topic list, and maybe in the future I will get to it.
Great video, As I remember, Laravel has "Signed URLs" feature, which helps to prevent from changing the URL,would it be better if we apply this feature to accept URL in the email?
In addition to "Signed URLs" feature, I think we can also generate a token by using Str::uuid() to make sure it is unique for each invitation email because Str::random() sometimes can generate a duplicate string for two invited different users and thus can cause the potential bug. What do you think?
Signed URLs are advised to use only for non-crucial features, like unsubscribe from email list. They are not considered secure enough, so I wouldn't probably use it for this use-case.
Newbie here, I have cloned the repo of this chapter and I try to register a new user. When I post the Register form, I get a 419 indicating csrf is missing. But in resources/views/auth/register.blade.php the @csrf is there.
Additional info: After cloning the repo, I ran
419 doesn't necessarily mean that CSRF is missing. Have you provided the correct URL in .env APP_URL? Have you made your storage folders writable for the session store?
Thanks, now I ran
I am on a Windows 10 machine APP_URL=localhost (also tried APP_URL=localhost:8000)
Still I get 419
If I try another repo, like LaravelDaily/Laravel-9-Beginners-Admin it works. I just do:
And then I set is_admin to 1 in users table. After that I can run both cruds. No 419.
Thank you for the course. Really great and helped me a lot! I'm not sure, but maybe a few comments here:
resources/views/auth/register.blade.php: < input type="hidden" value="{{ request('token') }}" > in the input is missing the attribute: name="token". < input type="hidden" name="token" value="{{ request('token') }}" >
resources/views/auth/register.blade.php: < x-text-input id="email" :value="old('email')" ... > is missing the $invitationEmail < x-text-input id="email" :value="old('email', $invitationEmail)" ... >