starter-kit-upgrade: Unified security, Vite 8, npm policy, passkeys

Upstream: laravel/livewire-starter-kit@6e000a1 @3b84d57 @89c9953 @43dc8f1 (combined; manifests overlap)

- Consolidate password/2FA/passkeys on settings/security with password.confirm
- Vite 8 + laravel-vite-plugin ^3, remove axios, add .npmrc ignore-scripts
- Fortify passkeys + rate limiter, User PasskeyAuthenticatable, passkey UI and migration

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Benjamin Nussbaum
2026-05-26 18:08:51 +02:00
co-authored by Cursor
parent f2ff4ad111
commit c5bf1bcd40
22 changed files with 1204 additions and 965 deletions
+1
View File
@@ -0,0 +1 @@
ignore-scripts=true
+6 -2
View File
@@ -8,13 +8,15 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable // implements MustVerifyEmail
class User extends Authenticatable implements PasskeyUser // implements MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
use HasApiTokens, HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
@@ -39,6 +41,8 @@ class User extends Authenticatable // implements MustVerifyEmail
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
/**
+8
View File
@@ -68,5 +68,13 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('passkeys', function (Request $request) {
$credentialId = $request->input('credential.id');
return Limit::perMinute(10)->by(
($credentialId ?: $request->session()->getId()).'|'.$request->ip(),
);
});
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"bnussbau/laravel-trmnl-blade": "^2.3",
"bnussbau/epaper-pipeline-php": "^1.0",
"keepsuit/laravel-liquid": "^0.6",
"laravel/fortify": "^1.30",
"laravel/fortify": "^1.37",
"laravel/framework": "^13.0",
"laravel/sanctum": "^4.0",
"laravel/socialite": "^5.23",
Generated
+93 -94
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -117,6 +117,7 @@ return [
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
'passkeys' => 'passkeys',
],
/*
@@ -154,6 +155,9 @@ return [
'confirmPassword' => true,
// 'window' => 0,
]),
Features::passkeys([
'confirmPassword' => true,
]),
],
];
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Laravel\Passkeys\Passkeys;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('passkeys', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Passkeys::userModel(), 'user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('credential_id')->unique();
$table->json('credential');
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('passkeys');
}
};
+361 -597
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -1,4 +1,5 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
@@ -16,18 +17,18 @@
"@codemirror/language": "^6.11.3",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.38.5",
"@fsegurai/codemirror-theme-github-light": "^6.2.2",
"@laravel/passkeys": "^0.2.0",
"@tailwindcss/vite": "^4.1.11",
"autoprefixer": "^10.4.20",
"axios": "^1.8.2",
"codemirror": "^6.0.2",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0",
"laravel-vite-plugin": "^3.1",
"puppeteer": "25.0.4",
"tailwindcss": "^4.0.7",
"vite": "^7.3.2"
"vite": "^8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",
+4
View File
@@ -0,0 +1,4 @@
import { Passkeys } from '@laravel/passkeys';
window.Passkeys = Passkeys;
window.dispatchEvent(new CustomEvent('passkeys:ready'));
@@ -0,0 +1,94 @@
@assets
@vite('resources/js/passkeys.js')
@endassets
<div
x-data="{
supported: false,
showForm: false,
name: '',
loading: false,
error: null,
updateSupport() {
this.supported = Boolean(window.Passkeys?.isSupported());
},
init() {
this.updateSupport();
window.addEventListener('passkeys:ready', () => this.updateSupport(), { once: true });
},
async register() {
if (!this.name.trim()) return;
this.loading = true;
this.error = null;
try {
await window.Passkeys.register({ name: this.name });
this.name = '';
this.showForm = false;
await $wire.loadPasskeys();
} catch (e) {
if (e.constructor?.name !== 'UserCancelledError') {
this.error = e.message;
}
} finally {
this.loading = false;
}
},
cancel() {
this.showForm = false;
this.name = '';
this.error = null;
},
}"
>
<template x-if="!supported">
<flux:text>{{ __('Passkeys are not supported in this browser.') }}</flux:text>
</template>
<template x-if="supported && !showForm">
<div>
<flux:button
variant="primary"
icon="plus"
x-on:click="showForm = true"
>
{{ __('Add passkey') }}
</flux:button>
</div>
</template>
<template x-if="supported && showForm">
<div class="space-y-4 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 p-4">
<flux:input
label="{{ __('Passkey name') }}"
x-model="name"
placeholder="{{ __('e.g., MacBook Pro, iPhone') }}"
x-on:keydown.enter.prevent="register()"
x-ref="passkeyNameInput"
x-init="$nextTick(() => $refs.passkeyNameInput?.focus())"
/>
<flux:text class="!mt-1">{{ __('Give this passkey a name to help you identify it later.') }}</flux:text>
<p x-show="error" x-text="error" x-cloak class="text-sm text-red-600 dark:text-red-400"></p>
<div class="flex gap-2">
<flux:button
variant="primary"
x-on:click="register()"
x-bind:disabled="loading || !name.trim()"
>
<span x-show="!loading">{{ __('Register passkey') }}</span>
<span x-show="loading" x-cloak>{{ __('Registering...') }}</span>
</flux:button>
<flux:button
variant="ghost"
x-on:click="cancel()"
>
{{ __('Cancel') }}
</flux:button>
</div>
</div>
</template>
</div>
@@ -0,0 +1,76 @@
@props([
'optionsRoute' => 'passkey.login-options',
'submitRoute' => 'passkey.login',
'label' => __('Sign in with a passkey'),
'loadingLabel' => __('Authenticating...'),
'separator' => __('Or continue with email'),
])
@assets
@vite('resources/js/passkeys.js')
@endassets
<div
x-data="{
supported: false,
loading: false,
error: null,
updateSupport() {
this.supported = Boolean(window.Passkeys?.isSupported());
},
init() {
this.updateSupport();
window.addEventListener('passkeys:ready', () => this.updateSupport(), { once: true });
},
async verify() {
this.loading = true;
this.error = null;
try {
const response = await window.Passkeys.verify({
routes: {
options: '{{ route($optionsRoute) }}',
submit: '{{ route($submitRoute) }}',
},
});
Livewire.navigate(response.redirect || '/dashboard');
} catch (e) {
if (e.constructor?.name !== 'UserCancelledError') {
this.error = e.message;
}
} finally {
this.loading = false;
}
},
}"
>
<template x-if="supported">
<div>
<div class="grid gap-2">
<flux:button
variant="outline"
icon="finger-print"
class="w-full"
x-on:click="verify()"
x-bind:disabled="loading"
>
<span x-show="!loading">{{ $label }}</span>
<span x-show="loading" x-cloak>{{ $loadingLabel }}</span>
</flux:button>
<p x-show="error" x-text="error" x-cloak
class="text-sm text-center text-red-600 dark:text-red-400"></p>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-zinc-200 dark:border-zinc-700"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="px-2 text-zinc-500 dark:text-zinc-400 bg-white dark:bg-zinc-900">
{{ $separator }}
</span>
</div>
</div>
</div>
</template>
</div>
@@ -7,6 +7,14 @@
<x-auth-session-status class="text-center" :status="session('status')" />
<x-passkey-verify
options-route="passkey.confirm-options"
submit-route="passkey.confirm"
:label="__('Confirm with passkey')"
:loading-label="__('Confirming...')"
:separator="__('Or confirm with password')"
/>
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
@csrf
@@ -5,6 +5,8 @@
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<x-passkey-verify />
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
@csrf
@@ -4,11 +4,11 @@
<flux:navlist.item :href="route('settings.preferences')" wire:navigate>{{ __('Preferences') }}</flux:navlist.item>
<flux:navlist.item :href="route('appearance.edit')" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
<flux:navlist.item :href="route('profile.edit')" wire:navigate>{{ __('Profile') }}</flux:navlist.item>
@if(auth()?->user()?->oidc_sub === null)
<flux:navlist.item :href="route('user-password.edit')" wire:navigate>{{ __('Password') }}</flux:navlist.item>
@if (auth()?->user()?->oidc_sub === null)
<flux:navlist.item :href="route('security.edit')" wire:navigate>{{ __('Security') }}</flux:navlist.item>
@endif
<flux:navlist.item :href="route('settings.support')" wire:navigate>{{ __('Support') }}</flux:navlist.item>
@if(config('app.version'))
@if (config('app.version'))
<flux:navlist.item :href="route('settings.update')" wire:navigate>{{ __('Updates') }}</flux:navlist.item>
@endif
</flux:navlist>
@@ -1,92 +0,0 @@
<?php
use App\Concerns\PasswordValidationRules;
use Flux\Flux;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
new class extends Component
{
use PasswordValidationRules;
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Update the password for the currently authenticated user.
*/
public function updatePassword(): void
{
try {
$validated = $this->validate([
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
throw $e;
}
Auth::user()->update([
'password' => $validated['password'],
]);
$this->reset('current_password', 'password', 'password_confirmation');
Flux::toast(variant: 'success', text: __('Saved.'));
}
}; ?>
<section class="w-full py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Password Settings') }}</flux:heading>
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<form method="POST" wire:submit="updatePassword" class="mt-6 space-y-6">
<flux:input
wire:model="current_password"
:label="__('Current password')"
type="password"
required
autocomplete="current-password"
viewable
/>
<flux:input
wire:model="password"
:label="__('New password')"
type="password"
required
autocomplete="new-password"
viewable
/>
<flux:input
wire:model="password_confirmation"
:label="__('Confirm Password')"
type="password"
required
autocomplete="new-password"
viewable
/>
<div class="flex items-center gap-4">
<flux:button variant="primary" type="submit" data-test="update-password-button">
{{ __('Save') }}
</flux:button>
</div>
</form>
@if (Laravel\Fortify\Features::canManageTwoFactorAuthentication() && auth()?->user()?->oidc_sub === null)
<flux:heading class="mt-6">2FA</flux:heading>
<flux:subheading class="mb-4">Optionally, you can enable Two-Factor Authentication via TOTP</flux:subheading>
<flux:button :href="route('two-factor.show')" wire:navigate>{{ __('2FA Settings…') }}</flux:button>
@endif
</x-pages::settings.layout>
</div>
</section>
@@ -0,0 +1,341 @@
<?php
use App\Concerns\PasswordValidationRules;
use Flux\Flux;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Livewire\Attributes\Title;
use Livewire\Component;
use Laravel\Passkeys\Actions\DeletePasskey;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
new #[Title('Security settings')] class extends Component {
use PasswordValidationRules;
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public bool $canManageTwoFactor;
public bool $twoFactorEnabled;
public bool $requiresConfirmation;
#[Locked]
public bool $canManagePasskeys;
#[Locked]
public array $passkeys = [];
public bool $showDeleteModal = false;
#[Locked]
public ?int $deletingPasskeyId = null;
#[Locked]
public string $deletingPasskeyName = '';
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$this->canManageTwoFactor = Features::canManageTwoFactorAuthentication();
if ($this->canManageTwoFactor) {
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
$disableTwoFactorAuthentication(auth()->user());
}
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
$this->canManagePasskeys = Features::canManagePasskeys();
if ($this->canManagePasskeys) {
$this->loadPasskeys();
}
}
/**
* Update the password for the currently authenticated user.
*/
public function updatePassword(): void
{
try {
$validated = $this->validate([
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
throw $e;
}
Auth::user()->update([
'password' => $validated['password'],
]);
$this->reset('current_password', 'password', 'password_confirmation');
Flux::toast(variant: 'success', text: __('Password updated.'));
}
/**
* Load the user's passkeys.
*/
public function loadPasskeys(): void
{
$this->passkeys = auth()->user()->passkeys()
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
->latest()
->get()
->map(fn ($passkey) => [
'id' => $passkey->id,
'name' => $passkey->name,
'authenticator' => $passkey->authenticator,
'created_at_diff' => $passkey->created_at->diffForHumans(),
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
])
->toArray();
}
/**
* Show the delete confirmation modal.
*/
public function confirmDelete(int $passkeyId): void
{
$passkey = auth()->user()->passkeys()->findOrFail($passkeyId);
$this->deletingPasskeyId = $passkey->id;
$this->deletingPasskeyName = $passkey->name;
$this->showDeleteModal = true;
}
/**
* Delete the passkey.
*/
public function deletePasskey(DeletePasskey $deletePasskey): void
{
if (! $this->deletingPasskeyId) {
return;
}
$passkey = auth()->user()->passkeys()->findOrFail($this->deletingPasskeyId);
$deletePasskey(auth()->user(), $passkey);
$this->closeDeleteModal();
$this->loadPasskeys();
}
/**
* Close the delete confirmation modal.
*/
public function closeDeleteModal(): void
{
$this->showDeleteModal = false;
$this->deletingPasskeyId = null;
$this->deletingPasskeyName = '';
}
/**
* Handle the two-factor authentication enabled event.
*/
#[On('two-factor-enabled')]
public function onTwoFactorEnabled(): void
{
$this->twoFactorEnabled = true;
}
/**
* Disable two-factor authentication for the user.
*/
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$disableTwoFactorAuthentication(auth()->user());
$this->twoFactorEnabled = false;
}
}; ?>
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Security settings') }}</flux:heading>
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<form method="POST" wire:submit="updatePassword" class="mt-6 space-y-6">
<flux:input
wire:model="current_password"
:label="__('Current password')"
type="password"
required
autocomplete="current-password"
viewable
/>
<flux:input
wire:model="password"
:label="__('New password')"
type="password"
required
autocomplete="new-password"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<flux:input
wire:model="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
passwordrules="{{ \Illuminate\Validation\Rules\Password::defaults()->toPasswordRulesString() }}"
viewable
/>
<div class="flex items-center gap-4">
<flux:button variant="primary" type="submit" data-test="update-password-button">
{{ __('Save') }}
</flux:button>
</div>
</form>
@if ($canManageTwoFactor)
<section class="mt-12">
<flux:heading>{{ __('Two-factor authentication') }}</flux:heading>
<flux:subheading>{{ __('Manage your two-factor authentication settings') }}</flux:subheading>
<div class="flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
@if ($twoFactorEnabled)
<div class="space-y-4">
<flux:text>
{{ __('You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
</flux:text>
<div class="flex justify-start">
<flux:button
variant="danger"
wire:click="disable"
>
{{ __('Disable 2FA') }}
</flux:button>
</div>
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
</div>
@else
<div class="space-y-4">
<flux:text variant="subtle">
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
</flux:text>
<flux:modal.trigger name="two-factor-setup-modal">
<flux:button
variant="primary"
wire:click="$dispatch('start-two-factor-setup')"
>
{{ __('Enable 2FA') }}
</flux:button>
</flux:modal.trigger>
<livewire:pages::settings.two-factor-setup-modal :requires-confirmation="$requiresConfirmation" />
</div>
@endif
</div>
</section>
@endif
@if ($canManagePasskeys)
<section class="mt-12">
<flux:heading>{{ __('Passkeys') }}</flux:heading>
<flux:subheading>{{ __('Manage your passkeys for passwordless sign-in') }}</flux:subheading>
<div class="mt-6 flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
<div class="border rounded-lg border-zinc-200 dark:border-zinc-700 overflow-hidden">
@forelse ($passkeys as $passkey)
<div class="flex items-center justify-between p-4 {{ ! $loop->last ? 'border-b border-zinc-200 dark:border-zinc-700' : '' }}">
<div class="flex items-center gap-4">
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-zinc-100 dark:bg-zinc-800">
<flux:icon.key class="size-5 text-zinc-500 dark:text-zinc-400" />
</div>
<div class="space-y-1">
<div class="flex items-center gap-2.5">
<p class="font-medium tracking-tight">{{ $passkey['name'] }}</p>
@if ($passkey['authenticator'])
<flux:badge size="sm">{{ $passkey['authenticator'] }}</flux:badge>
@endif
</div>
<p class="text-zinc-500 dark:text-zinc-400 text-xs">
{{ __('Added :time', ['time' => $passkey['created_at_diff']]) }}
@if ($passkey['last_used_at_diff'])
<span class="opacity-50 mx-1">/</span>
{{ __('Last used :time', ['time' => $passkey['last_used_at_diff']]) }}
@endif
</p>
</div>
</div>
<flux:button
variant="ghost"
size="sm"
icon="trash"
icon:variant="outline"
wire:click="confirmDelete({{ $passkey['id'] }})"
class="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/50"
/>
</div>
@empty
<div class="p-8 text-center">
<div class="mx-auto mb-4 flex size-14 items-center justify-center rounded-2xl bg-zinc-100 dark:bg-zinc-800">
<flux:icon.key class="size-7 text-zinc-400 dark:text-zinc-500" />
</div>
<p class="font-medium">{{ __('No passkeys yet') }}</p>
<flux:text class="mt-1">{{ __('Add a passkey to sign in without a password') }}</flux:text>
</div>
@endforelse
</div>
<x-passkey-registration />
</div>
</section>
@endif
</x-pages::settings.layout>
<flux:modal
name="delete-passkey-modal"
class="max-w-md md:min-w-md"
@close="closeDeleteModal"
wire:model="showDeleteModal"
>
<div class="space-y-6">
<div class="space-y-2">
<flux:heading size="lg">{{ __('Remove passkey') }}</flux:heading>
<flux:text>
{{ __('Are you sure you want to remove the passkey ":name"? You will no longer be able to use it to sign in.', ['name' => $deletingPasskeyName]) }}
</flux:text>
</div>
<div class="flex gap-3 justify-end">
<flux:button
variant="outline"
wire:click="closeDeleteModal"
>
{{ __('Cancel') }}
</flux:button>
<flux:button
variant="danger"
wire:click="deletePasskey"
>
{{ __('Remove passkey') }}
</flux:button>
</div>
</div>
</flux:modal>
</section>
@@ -1,21 +1,14 @@
<?php
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Symfony\Component\HttpFoundation\Response;
new class extends Component
{
#[Locked]
public bool $twoFactorEnabled;
new class extends Component {
#[Locked]
public bool $requiresConfirmation;
@@ -25,42 +18,28 @@ new class extends Component
#[Locked]
public string $manualSetupKey = '';
public bool $showModal = false;
public bool $showVerificationStep = false;
public bool $setupComplete = false;
#[Validate('required|string|size:6', onUpdate: false)]
public string $code = '';
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
public function mount(bool $requiresConfirmation): void
{
abort_unless(Features::enabled(Features::twoFactorAuthentication()), Response::HTTP_FORBIDDEN);
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
$disableTwoFactorAuthentication(auth()->user());
}
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
$this->requiresConfirmation = $requiresConfirmation;
}
/**
* Enable two-factor authentication for the user.
*/
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
#[On('start-two-factor-setup')]
public function startTwoFactorSetup(): void
{
$enableTwoFactorAuthentication = app(EnableTwoFactorAuthentication::class);
$enableTwoFactorAuthentication(auth()->user());
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
$this->loadSetupData();
$this->showModal = true;
}
/**
@@ -68,10 +47,14 @@ new class extends Component
*/
private function loadSetupData(): void
{
$user = auth()->user();
$user = auth()->user()?->fresh();
try {
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
if (! $user || ! $user->two_factor_secret) {
throw new Exception('Two-factor setup secret is not available.');
}
$this->qrCodeSvg = $user->twoFactorQrCodeSvg();
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
@@ -94,6 +77,7 @@ new class extends Component
}
$this->closeModal();
$this->dispatch('two-factor-enabled');
}
/**
@@ -105,9 +89,11 @@ new class extends Component
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
$this->setupComplete = true;
$this->closeModal();
$this->twoFactorEnabled = true;
$this->dispatch('two-factor-enabled');
}
/**
@@ -120,16 +106,6 @@ new class extends Component
$this->resetErrorBag();
}
/**
* Disable two-factor authentication for the user.
*/
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$disableTwoFactorAuthentication(auth()->user());
$this->twoFactorEnabled = false;
}
/**
* Close the two-factor authentication modal.
*/
@@ -139,15 +115,11 @@ new class extends Component
'code',
'manualSetupKey',
'qrCodeSvg',
'showModal',
'showVerificationStep',
'setupComplete',
);
$this->resetErrorBag();
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
}
/**
@@ -156,9 +128,9 @@ new class extends Component
#[Computed]
public function modalConfig(): array
{
if ($this->twoFactorEnabled) {
if ($this->setupComplete) {
return [
'title' => __('Two-Factor Authentication Enabled'),
'title' => __('Two-factor authentication enabled'),
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Close'),
];
@@ -166,81 +138,25 @@ new class extends Component
if ($this->showVerificationStep) {
return [
'title' => __('Verify Authentication Code'),
'title' => __('Verify authentication code'),
'description' => __('Enter the 6-digit code from your authenticator app.'),
'buttonText' => __('Continue'),
];
}
return [
'title' => __('Enable Two-Factor Authentication'),
'title' => __('Enable two-factor authentication'),
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Continue'),
];
}
} ?>
}; ?>
<section class="w-full py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Two-Factor Authentication Settings') }}</flux:heading>
<x-pages::settings.layout
:heading="__('Two Factor Authentication')"
:subheading="__('Manage your two-factor authentication settings')"
>
<div class="flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
@if ($twoFactorEnabled)
<div class="space-y-4">
<div class="flex items-center gap-3">
<flux:badge color="green">{{ __('Enabled') }}</flux:badge>
</div>
<flux:text>
{{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
</flux:text>
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
<div class="flex justify-start">
<flux:button
icon="shield-exclamation"
icon:variant="outline"
wire:click="disable"
>
{{ __('Disable 2FA') }}
</flux:button>
</div>
</div>
@else
<div class="space-y-4">
<div class="flex items-center gap-3">
<flux:badge color="red">{{ __('Disabled') }}</flux:badge>
</div>
<flux:text variant="subtle">
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
</flux:text>
<flux:button
icon="shield-check"
icon:variant="outline"
wire:click="enable"
>
{{ __('Enable 2FA') }}
</flux:button>
</div>
@endif
</div>
</x-pages::settings.layout>
<flux:modal
name="two-factor-setup-modal"
class="max-w-md md:min-w-md"
@close="closeModal"
wire:model="showModal"
>
<flux:modal
name="two-factor-setup-modal"
class="max-w-md md:min-w-md"
@close="closeModal"
>
<div class="space-y-6">
<div class="flex flex-col items-center space-y-4">
<div class="p-0.5 w-auto rounded-full border border-stone-100 dark:border-stone-600 bg-white dark:bg-stone-800 shadow-sm">
@@ -391,6 +307,4 @@ new class extends Component
</div>
@endif
</div>
</flux:modal>
</div>
</section>
</flux:modal>
+11 -14
View File
@@ -1,24 +1,21 @@
<?php
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Features;
Route::middleware(['auth'])->group(function () {
Route::middleware(['auth'])->group(function (): void {
Route::redirect('settings', 'settings/profile');
Route::livewire('settings/preferences', 'pages::settings.preferences')->name('settings.preferences');
Route::livewire('settings/profile', 'pages::settings.profile')->name('profile.edit');
Route::livewire('settings/password', 'pages::settings.password')->name('user-password.edit');
Route::livewire('settings/appearance', 'pages::settings.appearance')->name('appearance.edit');
Route::livewire('settings/two-factor', 'pages::settings.two-factor')
->middleware(
when(
Features::canManageTwoFactorAuthentication()
&& Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'),
['password.confirm'],
[],
),
)
->name('two-factor.show');
Route::redirect('settings/password', '/settings/security');
Route::redirect('settings/two-factor', '/settings/security');
Route::livewire('settings/support', 'pages::settings.support')->name('settings.support');
Route::livewire('settings/update', 'pages::settings.update')->name('settings.update');
});
Route::middleware(['auth', 'verified'])->group(function (): void {
Route::livewire('settings/appearance', 'pages::settings.appearance')->name('appearance.edit');
Route::livewire('settings/security', 'pages::settings.security')
->middleware(['password.confirm'])
->name('security.edit');
});
@@ -1,40 +0,0 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('password can be updated', function (): void {
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
$this->actingAs($user);
$response = Livewire::test('pages::settings.password')
->set('current_password', 'password')
->set('password', 'new-password')
->set('password_confirmation', 'new-password')
->call('updatePassword');
$response->assertHasNoErrors();
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
});
test('correct password must be provided to update password', function (): void {
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
$this->actingAs($user);
$response = Livewire::test('pages::settings.password')
->set('current_password', 'wrong-password')
->set('password', 'new-password')
->set('password_confirmation', 'new-password')
->call('updatePassword');
$response->assertHasErrors(['current_password']);
});

Some files were not shown because too many files have changed in this diff Show More