Modules
Teams and Two-Factor
Two-factor authentication and per-team roles, switched on in an application that already has this panel — what Fortify owns, what the permission layer owns, and the few lines that are yours.
The profile card, with a key registered through a real WebAuthn ceremony. Adding one opens the platform's own dialog; removing goes through the package's own action.
On this page
Two features every admin panel is eventually asked for, and neither of them is implemented here.
Laravel Fortify owns two-factor. The secret, the TOTP window, the recovery codes, the challenge screen on the way in and the rate limiting around it are a security surface with a maintained owner. The permission layer owns teams — roles scoped to a team, the extra column on the pivot tables, the cache key that has to include it.
The permission layer here is always nyoncode/laravel-permission-extended. It is
not an alternative to spatie/laravel-permission — it requires it and extends it
with wildcard matching, a super-admin gate, Blade directives and
permission-change events — and those are what this framework's role screens
assume. A model on bare Spatie is deliberately not detected, because a
management UI over half an authorization model is worse than none.
What both deliberately leave out is the screen. This module supplies the screen and calls their actions, so a panel gets the feature without a second implementation of it sitting next to the first.
composer require laravel/fortify # two-factorcomposer require nyoncode/laravel-permission-extended # roles and teams
How It Works
Detection, not configuration. Both features are auto by default and both
autos look for the thing itself:
| Feature | What auto checks |
Where |
|---|---|---|
| Two-factor | Fortify's EnableTwoFactorAuthentication class exists and Features::enabled(Features::twoFactorAuthentication()) |
Support\TwoFactor |
| Teams | nyoncode/laravel-permission-extended is installed, config('permission.teams') is true, the registrar class exists, and teams.model resolves |
Support\Teams |
Two conditions rather than one, in both cases, and both were worth checking.
Fortify is a dependency of several things an application may have installed for
other reasons, and its two-factor feature is a line in fortify.features that
can simply be left out — installed-but-off has to read as off, or the card
appears and its buttons throw. Teams follows permission.teams rather than a
switch of its own, because that setting is what makes the feature real: it is
what puts the team column on the pivot tables and into the permission cache key.
A panel that disagreed with it would be showing a switcher over authorization
that is not scoped.
Spatie's PermissionRegistrar is still what the team id is set on, and that is
not a contradiction: the extended package adds behaviour to the user model and
inherits the registrar untouched, so it remains the one object that knows what a
permission read is scoped to.
Two-factor has three states, not two. Fortify writes the secret the moment the QR code is generated, so a person who opened the panel and closed the tab has one and is protected by nothing:
| State | Means | The card shows |
|---|---|---|
| off | no secret | one button: turn it on |
| pending | a secret, not confirmed | the QR code, the setup key, six code boxes — and a way back out |
| on | two_factor_confirmed_at is set |
recovery codes, and a way off |
A panel that modelled this as a boolean would strand every interrupted setup in
the middle state while the badge told them they were safe. The badge reads
"Not finished" there, and Turn it off is offered from it as well as from on —
a half-finished setup has to be leavable in both directions or it is a trap.
The team scopes everything, so it is set before anything reads it. With
permission.teams on, every role and permission lookup is scoped by whatever
team id the registrar was last told about — so a request that never tells it sees
the previous one, which in a queue worker or a long-lived process is somebody
else's. The module pushes SetCurrentTeam onto the web middleware group, and
onto the group rather than behind an alias a route opts into: a page that forgot
the alias would authorize against the wrong team while looking perfectly correct.
Switching redirects. The team scopes every permission read, so a page
composed before the switch — its menu, its actions, the rows a policy let
through — was built for the team you just left. A fresh request is the only
honest answer, and TeamSwitcher issues one.
Membership is checked on the switch, not only in the list. The switcher lists
the teams you belong to, and Teams::switchTo() re-checks membership before
storing anything. A <select> is markup, and markup is whatever reached the
browser.
The current team falls back rather than failing. The session names one; if it names a team you are no longer in — or names nothing yet — the first team you belong to is used. Both cases are ordinary, and neither should land a person on a page scoped to nothing.
Switching On Two-Factor
Install Fortify, run its migration, and enable the feature. Nothing else in this panel changes:
// config/fortify.phpuse Laravel\Fortify\Features; 'features' => [ Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, ]), ],
// app/Models/User.phpuse Illuminate\Foundation\Auth\User as Authenticatable;use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable{ use TwoFactorAuthenticatable; }
The card appears on the profile page the moment both are true. 'confirm' => false
is honoured too — the module reads it off Fortify's own feature options rather
than a copy of the setting — and a secret then is the whole of the setup, so
the pending state does not occur.
The code is typed into the same field on both sides of the door. The boxes on
this card are wire-forms' OtpInput, which is what the sign-in challenge
draws too — they advance themselves, take a pasted code apart, and post one
value. There is no second opinion here about what six digits look like.
The challenge belongs to the other half. This module owns the management card — the QR code, the recovery codes, the switch. The screen that asks for a code while somebody signs in is the auth module's, next to the login form, and the mechanism under both is the same Fortify. That split is deliberate rather than accidental: this module detects Fortify and works fine without it, and a package whose only job is Fortify's screens requires it. See the auth module.
Answering for yourself
// config/wire-module-users.php'two_factor' => false, // never show the card, whatever is installed
'profile' => [ 'two_factor' => false, // keep it out of the profile, mount it elsewhere ],
The second is the one to reach for when you want the card on a security page of
your own — @livewire(\NyonCode\WireModuleUsers\Livewire\TwoFactorAuthentication::class)
takes no arguments, because the account it works on is always the one signed in.
Switching On Teams
Teams need three things from your application: the setting, a team model, and a relation on the user that reaches it. This module ships none of them, because an application that has teams already has all three.
// config/permission.php — spatie/laravel-permission's own, published by the// extended package's dependency'teams' => true, 'team_foreign_key' => 'team_id',
// config/wire-module-users.php'teams' => [ 'enabled' => 'auto', // follows permission.teams 'model' => 'App\Models\Team', 'relation' => 'teams', 'label_attribute' => 'name', 'session_key' => 'wire.team',],
// app/Models/User.phpuse Illuminate\Database\Eloquent\Relations\BelongsToMany;use Illuminate\Foundation\Auth\User as Authenticatable;use NyonCode\PermissionExtended\Traits\HasRoles; // this one, never Spatie's class User extends Authenticatable{ use HasRoles; /** The relation `wire-module-users.teams.relation` names. */ public function teams(): BelongsToMany { return $this->belongsToMany(Team::class); } }
That is enough. A switcher appears in the top bar for anyone who belongs to more
than one team, the middleware scopes every permission read on every web request,
and Gate::allows() starts answering per team without a single call site
changing.
Where the switcher comes from
It is not in the shell's layout, and this module never edits that file. It
registers a view with the chrome registry in wire-core, and the shell renders
whatever is registered — the same seam the media picker uses at the other end of
the document:
// packages/module-users/src/WireModuleUsersServiceProvider.phpuse NyonCode\WireCore\Foundation\View\PageChrome; protected function bootTeams(): void{ if (! Teams::enabled()) { return; } $this->app->make(Kernel::class)->appendMiddlewareToGroup('web', SetCurrentTeam::class); $this->app->make(PageChrome::class)->add( 'wire-module-users::team-switcher', PageChrome::TOPBAR, ); }
PageChrome::TOPBAR is the region for things that have to be seen — a team
switcher, a tenant picker, an environment badge. PageChrome::BODY, the default,
is for things that only have to exist: a modal something else opens, a
confirmation host. Your own application can register into either, and so can any
other package:
// app/Providers/AppServiceProvider.phppublic function boot(): void{ app(PageChrome::class)->add('partials.environment-badge', PageChrome::TOPBAR); }
Passkeys On The Profile Page
The third thing an account can be secured with, and the newest: a passkey is the
platform's own credential — Touch ID, Windows Hello, a phone, a security key.
Laravel owns all of it, exactly as it owns two-factor: laravel/passkeys
behind Fortify's Features::passkeys() ships the WebAuthn ceremony, the
credential rows and the browser client. This module adds the card.
// config/wire-module-users.php'profile' => [ 'passkeys' => true, // the card, where the feature is on ], 'passkeys' => 'auto', // 'auto' looks for the packages, with the feature on
Two lines on the user model, and they are the ones whose absence is silent — every route answers and the key that gets registered belongs to nobody, so the card checks for them and says which are missing:
use Laravel\Passkeys\Contracts\PasskeyUser;use Laravel\Passkeys\PasskeyAuthenticatable; class User extends Authenticatable implements PasskeyUser { use PasskeyAuthenticatable; }
The card lists what the account has, takes a name for a new one and opens the
platform's dialog, and removes one through the package's own action — so
PasskeyDeleted fires for anything listening.
The sign-in button belongs to the other half, next to the login form, for the
same reason the two-factor challenge does. See the auth
module — including the local-development trap: browse
localhost, never 127.0.0.1.
Extended Example
Everything above, in one application: a users table with a photo, teams scoping authorization, and two-factor available to anybody who wants it.
// app/Models/User.phpnamespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsToMany;use Illuminate\Foundation\Auth\User as Authenticatable;use Laravel\Fortify\TwoFactorAuthenticatable;use NyonCode\WireCore\Foundation\Contracts\HasAvatar;use NyonCode\PermissionExtended\Traits\HasRoles;use NyonCode\WireModuleUsers\Concerns\InteractsWithAvatar; class User extends Authenticatable implements HasAvatar{ use HasRoles; use InteractsWithAvatar; // the photo, from the configured column use TwoFactorAuthenticatable; // the secret and the recovery codes, Fortify's protected $fillable = ['name', 'email', 'password', 'avatar_path']; protected $hidden = ['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes']; /** What the top-bar switcher lists, and what Teams::switchTo() checks against. */ public function teams(): BelongsToMany { return $this->belongsToMany(Team::class); } }
// config/wire-module-users.phpreturn [ 'model' => App\Models\User::class, 'avatar' => [ 'enabled' => 'auto', // the column below decides 'column' => 'avatar_path', 'disk' => 'public', ], 'two_factor' => 'auto', // Fortify decides 'teams' => [ 'enabled' => 'auto', // permission.teams decides 'model' => App\Models\Team::class, 'relation' => 'teams', ], 'profile' => [ 'password' => true, 'two_factor' => true, 'delete_account' => false, ],];
// database/migrations/…_add_profile_columns_to_users_table.phpSchema::table('users', function (Blueprint $table): void { $table->string('avatar_path')->nullable(); });
Nothing else. Run php artisan about and the panel reports which of the four
optional halves this installation actually got:
Wire Module Users ....................................................... Avatars ......................................................... enabled Roles ........................................................... enabled Teams ........................................................... enabled Two-factor ...................................................... enabled User model ..................................... App\Models\User
What Each Package Owns
| Concern | Owner | This module's part |
|---|---|---|
Password hashing, the current_password rule |
Laravel | the card that asks, and keeping the session signed in after |
| Two-factor secrets, TOTP, recovery codes, the sign-in challenge | Fortify | the three-state card that drives Fortify's actions |
| Roles, permissions, team scoping, the permission cache | nyoncode/laravel-permission-extended, over the spatie/laravel-permission it requires | the role screens, and which team this request is in |
| Login, registration, password reset, e-mail verification | Fortify or Breeze | nothing — see the admin shell |
| Teams themselves: the table, the model, membership | your application | the switcher over what you already have |
Changing Two-Factor Or Passkeys Asks For The Password Again
Fortify puts password.confirm on every route that turns two-factor off, reads
a recovery code or removes a passkey, and defaults that on. These cards drive
Fortify's own actions over Livewire, which reaches them around that middleware
— so the guard is restated on the card itself rather than lost. Somebody holding
a borrowed session cannot turn a second factor off, read the TOTP secret, or
delete a passkey without knowing the password it all protects.
It reads the same window Laravel's own RequirePassword writes —
auth.password_confirmed_at, against auth.password_timeout — so confirming a
password anywhere in the application satisfies every one of these, and the
timeout is configured in exactly one place:
// config/auth.php'password_timeout' => 10800, // seconds; Laravel's default is three hours
Outside that window the buttons redirect to the confirmation screen and the card says why rather than silently showing an empty panel.
Related
- The Users Module — the profile page these cards live on
- The Admin Shell — the auth frame, and where a login screen goes
- Modules — what a module is, and how a package ships one