Skip to content

Commit 541aaee

Browse files
feat: settings, two-factor management, email verification screen and CI
What was left between this and a real starter kit. /settings holds the profile, the password, and two-factor: the QR code, the secret for anyone who cannot scan it, the recovery codes, and the confirmation step. Three pages rather than one, because Fortify puts the validation errors from all three forms in the same bag and a single page could not tell the reader which form had failed. A trap worth naming, and the reason the two-factor page is tested: with the confirm option on, hasEnabledTwoFactorAuthentication() stays false until a code has been entered. A page keyed on it — as this one was — offers "turn on" forever and never lets anyone finish. The state comes from the secret and the confirmation timestamp instead. The two-factor page asks for the password again, matching what Fortify enforces on the endpoints behind it, so it cannot show a secret those endpoints would then refuse to act on. The email verification screen is registered although the feature is off, so switching it on is the one uncommented line the README promises rather than one line and a missing view. CI runs the suite on push, building the assets first: @Vite throws without a manifest, which is exactly what makes a freshly created application look broken to whoever tries it first.
1 parent 3ec9c87 commit 541aaee

11 files changed

Lines changed: 419 additions & 5 deletions

File tree

.github/workflows/tests.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
8+
jobs:
9+
tests:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: shivammathur/setup-php@v2
16+
with:
17+
php-version: '8.3'
18+
extensions: sqlite3, pdo_sqlite
19+
coverage: none
20+
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: '20'
24+
25+
- name: Install PHP dependencies
26+
run: composer install --prefer-dist --no-interaction --no-progress
27+
28+
- name: Prepare the application
29+
run: |
30+
cp .env.example .env
31+
php artisan key:generate
32+
touch database/database.sqlite
33+
php artisan migrate --force
34+
35+
# The suite renders every page, and @vite throws without a manifest, so the
36+
# assets have to exist before the tests run. Skipping this is what makes a
37+
# freshly created application look broken to whoever tries it first.
38+
- name: Build the assets
39+
run: |
40+
npm ci
41+
npm run build
42+
43+
- name: Run the tests
44+
run: php artisan test

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,26 @@ The dashboard sits behind `auth`; the landing page and the component overview st
3838
because a kit that hides its own shop window shows nothing to the person deciding whether to
3939
use it.
4040

41+
## Settings
42+
43+
Under `/settings`, behind `auth`: the profile, the password, and two-factor authentication with
44+
its QR code, its recovery codes, and the confirmation step that only counts it as on once a code
45+
has actually worked. The two-factor page asks for the password again before it will show a
46+
secret, matching what Fortify enforces on the endpoints behind it.
47+
48+
Email verification is off, as it is in a plain Laravel install — but its screen is here and
49+
wired, so turning it on really is one uncommented line in `config/fortify.php` plus
50+
`MustVerifyEmail` on the user model.
51+
4152
## What is not here yet
4253

4354
Stated plainly, because a starter kit that pretends to be finished wastes your afternoon:
4455

45-
- **No settings or profile pages.** Fortify's update-profile and update-password endpoints are
46-
enabled, but there is no screen for them yet, and no screen to turn two-factor on.
4756
- **Passkeys are off.** Fortify ships them; they need a WebAuthn ceremony in JavaScript this kit
4857
does not have. A sign-in button that cannot sign anyone in is worse than no button.
49-
- **Email verification is off**, as it is in a plain Laravel install. Uncomment
50-
`Features::emailVerification()` and the `MustVerifyEmail` interface on the user model.
5158
- **The test suite fails until you build the assets.** `@vite` throws without a manifest, which
52-
is true of every Laravel starter kit; run `npm install && npm run build` first.
59+
is true of every Laravel starter kit; run `npm install && npm run build` first. CI does it in
60+
the right order.
5361

5462
## Requirements
5563

app/Providers/FortifyServiceProvider.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ public function boot(): void
4141
Fortify::confirmPasswordView(fn () => view('auth.confirm-password'));
4242
Fortify::twoFactorChallengeView(fn () => view('auth.two-factor-challenge'));
4343

44+
/*
45+
* Registered even though email verification is off in config/fortify.php:
46+
* turning the feature on should be one uncommented line, not one line and
47+
* then a missing view.
48+
*/
49+
Fortify::verifyEmailView(fn () => view('auth.verify-email'));
50+
4451
Fortify::createUsersUsing(CreateNewUser::class);
4552
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
4653
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<x-layouts.auth title="Verify your email" heading="Verify your email">
2+
<x-slot:subheading>We sent a link to the address you signed up with.</x-slot:subheading>
3+
4+
@if (session('status') === 'verification-link-sent')
5+
<x-aura::alert variant="success" class="mb-6">A new link is on its way.</x-aura::alert>
6+
@endif
7+
8+
<x-aura::text class="mb-6 text-sm">
9+
Click the link in that email to finish. If it never arrives, we can send another.
10+
</x-aura::text>
11+
12+
<div class="flex flex-wrap items-center gap-3">
13+
<form method="POST" action="{{ route('verification.send') }}">
14+
@csrf
15+
<x-aura::button type="submit" variant="primary">Send it again</x-aura::button>
16+
</form>
17+
18+
<form method="POST" action="{{ route('logout') }}">
19+
@csrf
20+
<x-aura::button type="submit" variant="ghost">Sign out</x-aura::button>
21+
</form>
22+
</div>
23+
</x-layouts.auth>

resources/views/components/layouts/app.blade.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
</x-slot:trigger>
5050

5151
<x-aura::dropdown.item href="{{ url('/dashboard') }}" icon="home">Dashboard</x-aura::dropdown.item>
52+
<x-aura::dropdown.item href="{{ route('settings.profile') }}" icon="settings">Settings</x-aura::dropdown.item>
5253
<x-aura::dropdown.separator />
5354
{{-- Signing out is a state change, so it is a POST with a token,
5455
never a link someone can be tricked into following. --}}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
@props(['current'])
2+
3+
@php
4+
$items = [
5+
['route' => 'settings.profile', 'label' => 'Profile'],
6+
['route' => 'settings.password', 'label' => 'Password'],
7+
['route' => 'settings.two-factor', 'label' => 'Two-factor'],
8+
];
9+
@endphp
10+
11+
<nav class="mb-8 flex flex-wrap gap-2" aria-label="Settings">
12+
@foreach ($items as $item)
13+
@php $active = $current === $item['route']; @endphp
14+
<a
15+
href="{{ route($item['route']) }}"
16+
@if ($active) aria-current="page" @endif
17+
class="rounded-lg px-3 py-1.5 text-sm no-underline transition
18+
{{ $active
19+
? 'bg-aura-primary-600 text-white'
20+
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800' }}"
21+
>{{ $item['label'] }}</a>
22+
@endforeach
23+
</nav>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<x-layouts.app title="Password">
2+
<x-aura::heading level="1">Settings</x-aura::heading>
3+
<x-settings-nav current="settings.password" />
4+
5+
@if (session('status') === 'password-updated')
6+
<x-aura::alert variant="success" class="mb-6">Your password has been changed.</x-aura::alert>
7+
@endif
8+
9+
<x-aura::card class="max-w-2xl">
10+
<x-aura::heading level="2" class="mb-1">Change password</x-aura::heading>
11+
<x-aura::text class="mb-6 text-sm">Use a long password you do not use anywhere else.</x-aura::text>
12+
13+
<form method="POST" action="{{ route('user-password.update') }}" class="aura-form-fluid space-y-5">
14+
@csrf
15+
@method('PUT')
16+
17+
<x-aura::password-input
18+
label="Current password"
19+
name="current_password"
20+
autocomplete="current-password"
21+
required
22+
:error="$errors->first('current_password')"
23+
/>
24+
25+
<x-aura::password-input
26+
label="New password"
27+
name="password"
28+
autocomplete="new-password"
29+
required
30+
:error="$errors->first('password')"
31+
/>
32+
33+
<x-aura::password-input
34+
label="Confirm new password"
35+
name="password_confirmation"
36+
autocomplete="new-password"
37+
required
38+
/>
39+
40+
<x-aura::button type="submit" variant="primary">Change password</x-aura::button>
41+
</form>
42+
</x-aura::card>
43+
</x-layouts.app>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<x-layouts.app title="Profile">
2+
<x-aura::heading level="1">Settings</x-aura::heading>
3+
<x-settings-nav current="settings.profile" />
4+
5+
@if (session('status') === 'profile-information-updated')
6+
<x-aura::alert variant="success" class="mb-6">Your profile has been updated.</x-aura::alert>
7+
@endif
8+
9+
<x-aura::card class="max-w-2xl">
10+
<x-aura::heading level="2" class="mb-1">Profile information</x-aura::heading>
11+
<x-aura::text class="mb-6 text-sm">The name and email address on your account.</x-aura::text>
12+
13+
<form method="POST" action="{{ route('user-profile-information.update') }}" class="aura-form-fluid space-y-5">
14+
@csrf
15+
@method('PUT')
16+
17+
<x-aura::input
18+
label="Name"
19+
name="name"
20+
value="{{ old('name', auth()->user()->name) }}"
21+
autocomplete="name"
22+
required
23+
:error="$errors->first('name')"
24+
/>
25+
26+
<x-aura::input
27+
label="Email"
28+
name="email"
29+
type="email"
30+
value="{{ old('email', auth()->user()->email) }}"
31+
autocomplete="username"
32+
required
33+
:error="$errors->first('email')"
34+
/>
35+
36+
<x-aura::button type="submit" variant="primary">Save</x-aura::button>
37+
</form>
38+
</x-aura::card>
39+
</x-layouts.app>
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<x-layouts.app title="Two-factor authentication">
2+
@php
3+
$user = auth()->user();
4+
5+
/*
6+
* Not hasEnabledTwoFactorAuthentication(): with the confirm option on, that
7+
* method stays false until the user has entered a code, so a page keyed on
8+
* it would offer "turn on" forever and never let anyone finish. What matters
9+
* here is whether a secret exists yet, and whether it has been confirmed.
10+
*/
11+
$started = ! is_null($user->two_factor_secret);
12+
$confirmed = ! is_null($user->two_factor_confirmed_at);
13+
@endphp
14+
15+
<x-aura::heading level="1">Settings</x-aura::heading>
16+
<x-settings-nav current="settings.two-factor" />
17+
18+
@if (session('status') === 'two-factor-authentication-enabled')
19+
<x-aura::alert variant="info" class="mb-6">
20+
Scan the code below with your authenticator app, then enter the six digits it shows to finish.
21+
</x-aura::alert>
22+
@endif
23+
24+
@if (session('status') === 'two-factor-authentication-confirmed')
25+
<x-aura::alert variant="success" class="mb-6">Two-factor authentication is on.</x-aura::alert>
26+
@endif
27+
28+
@if (session('status') === 'two-factor-authentication-disabled')
29+
<x-aura::alert variant="success" class="mb-6">Two-factor authentication is off.</x-aura::alert>
30+
@endif
31+
32+
<x-aura::card class="max-w-2xl">
33+
<div class="mb-1 flex items-center gap-3">
34+
<x-aura::heading level="2">Two-factor authentication</x-aura::heading>
35+
@if ($confirmed)
36+
<x-aura::badge variant="success">On</x-aura::badge>
37+
@elseif ($started)
38+
<x-aura::badge variant="warning">Waiting for confirmation</x-aura::badge>
39+
@else
40+
<x-aura::badge variant="secondary">Off</x-aura::badge>
41+
@endif
42+
</div>
43+
44+
<x-aura::text class="mb-6 text-sm">
45+
A second step at sign-in: a six-digit code from an app on your phone, so a stolen
46+
password is not enough on its own.
47+
</x-aura::text>
48+
49+
@if (! $started)
50+
<form method="POST" action="{{ route('two-factor.enable') }}">
51+
@csrf
52+
<x-aura::button type="submit" variant="primary">Turn on</x-aura::button>
53+
</form>
54+
@else
55+
@unless ($confirmed)
56+
{{-- The secret is shown once, while it is being set up, and never again.
57+
The QR is rendered by Fortify from the user's own secret. --}}
58+
<div class="mb-6 flex flex-wrap items-start gap-6">
59+
<div class="rounded-lg bg-white p-3">{!! $user->twoFactorQrCodeSvg() !!}</div>
60+
61+
<div class="text-sm">
62+
<p class="mb-2 text-gray-600 dark:text-gray-400">Cannot scan it? Enter this key by hand:</p>
63+
<code class="break-all">{{ decrypt($user->two_factor_secret) }}</code>
64+
</div>
65+
</div>
66+
67+
<form method="POST" action="{{ route('two-factor.confirm') }}" class="aura-form-fluid mb-8 space-y-4">
68+
@csrf
69+
70+
<x-aura::input
71+
label="Code from your app"
72+
name="code"
73+
inputmode="numeric"
74+
autocomplete="one-time-code"
75+
required
76+
:error="$errors->confirmTwoFactorAuthentication->first('code')"
77+
/>
78+
79+
<x-aura::button type="submit" variant="primary">Finish turning it on</x-aura::button>
80+
</form>
81+
@endunless
82+
83+
<div class="mb-8">
84+
<x-aura::heading level="3" class="mb-1">Recovery codes</x-aura::heading>
85+
<x-aura::text class="mb-4 text-sm">
86+
Each one signs you in once, for when the phone is not to hand. Keep them
87+
somewhere other than the phone.
88+
</x-aura::text>
89+
90+
<ul class="mb-4 grid gap-1 font-mono text-sm sm:grid-cols-2">
91+
@foreach ($user->recoveryCodes() as $code)
92+
<li>{{ $code }}</li>
93+
@endforeach
94+
</ul>
95+
96+
<form method="POST" action="{{ route('two-factor.regenerate-recovery-codes') }}">
97+
@csrf
98+
<x-aura::button type="submit" variant="secondary" size="sm">Generate new codes</x-aura::button>
99+
</form>
100+
</div>
101+
102+
<form method="POST" action="{{ route('two-factor.disable') }}">
103+
@csrf
104+
@method('DELETE')
105+
<x-aura::button type="submit" variant="danger">Turn off</x-aura::button>
106+
</form>
107+
@endif
108+
</x-aura::card>
109+
</x-layouts.app>

routes/web.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,15 @@
1313
'blocks' => $catalogue->blocks(),
1414
]);
1515
})->name('components');
16+
17+
/*
18+
* Fortify owns the endpoints behind these screens; the screens themselves are
19+
* ours. The two-factor page asks for the password again before it will show a
20+
* secret, matching the confirmPassword option in config/fortify.php — otherwise
21+
* it would display a QR code that the endpoints then refuse to act on.
22+
*/
23+
Route::middleware(['auth'])->prefix('settings')->name('settings.')->group(function () {
24+
Route::view('/profile', 'settings.profile')->name('profile');
25+
Route::view('/password', 'settings.password')->name('password');
26+
Route::view('/two-factor', 'settings.two-factor')->middleware('password.confirm')->name('two-factor');
27+
});

0 commit comments

Comments
 (0)