refactor: rebase on Livewire 4 starter kit

This commit is contained in:
Benjamin Nussbaum 2026-01-15 21:55:24 +01:00
parent b097b0a7d7
commit e660da46fb
69 changed files with 1967 additions and 942 deletions

View file

@ -8,7 +8,7 @@
x-show.transition.out.opacity.duration.1500ms="shown"
x-transition:leave.opacity.duration.1500ms
style="display: none"
{{ $attributes->merge(['class' => 'text-sm text-gray-600']) }}
{{ $attributes->merge(['class' => 'text-sm']) }}
>
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
</div>

View file

@ -3,7 +3,7 @@
'description',
])
<div class="flex w-full flex-col gap-2 text-center">
<h1 class="text-xl font-medium dark:text-zinc-200">{{ $title }}</h1>
<p class="text-center text-sm dark:text-zinc-400">{{ $description }}</p>
<div class="flex w-full flex-col text-center">
<flux:heading size="xl">{{ $title }}</flux:heading>
<flux:subheading>{{ $description }}</flux:subheading>
</div>

View file

@ -0,0 +1,39 @@
<flux:dropdown position="bottom" align="start">
<flux:sidebar.profile
{{ $attributes->only('name') }}
:initials="auth()->user()->initials()"
icon:trailing="chevrons-up-down"
data-test="sidebar-menu-button"
/>
<flux:menu>
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu.radio.group>
</flux:menu>
</flux:dropdown>

View file

@ -1,22 +0,0 @@
<div class="flex items-start max-md:flex-col">
<div class="mr-10 w-full pb-4 md:w-[220px]">
<flux:navlist>
<flux:navlist.item href="{{ route('settings.preferences') }}" wire:navigate>Preferences</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.profile') }}" wire:navigate>Profile</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.password') }}" wire:navigate>Password</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.appearance') }}" wire:navigate>Appearance</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.support') }}" wire:navigate>Support</flux:navlist.item>
</flux:navlist>
</div>
<flux:separator class="md:hidden" />
<div class="flex-1 self-stretch max-md:pt-6">
<flux:heading>{{ $heading ?? '' }}</flux:heading>
<flux:subheading>{{ $subheading ?? '' }}</flux:subheading>
<div class="mt-5 w-full max-w-lg">
{{ $slot }}
</div>
</div>
</div>

View file

@ -15,7 +15,7 @@
type="button"
class="group/disclosure-button mb-[2px] flex h-10 w-full items-center rounded-lg text-zinc-500 hover:bg-zinc-800/5 hover:text-zinc-800 lg:h-8 dark:text-white/80 dark:hover:bg-white/[7%] dark:hover:text-white"
>
<div class="pl-3 pr-4">
<div class="ps-3 pe-4">
<flux:icon.chevron-down class="hidden size-3! group-data-open/disclosure-button:block" />
<flux:icon.chevron-right class="block size-3! group-data-open/disclosure-button:hidden" />
</div>
@ -23,8 +23,8 @@
<span class="text-sm font-medium leading-none">{{ $heading }}</span>
</button>
<div class="relative hidden space-y-[2px] pl-7 data-open:block" @if ($expanded === true) data-open @endif>
<div class="absolute inset-y-[3px] left-0 ml-4 w-px bg-zinc-200 dark:bg-white/30"></div>
<div class="relative hidden space-y-[2px] ps-7 data-open:block" @if ($expanded === true) data-open @endif>
<div class="absolute inset-y-[3px] start-0 ms-4 w-px bg-zinc-200 dark:bg-white/30"></div>
{{ $slot }}
</div>

View file

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white dark:bg-zinc-800">
<flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.header>
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
<flux:sidebar.collapse class="lg:hidden" />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.group :heading="__('Platform')" class="grid">
<flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<flux:sidebar.nav>
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:sidebar.item>
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
{{ __('Documentation') }}
</flux:sidebar.item>
</flux:sidebar.nav>
<x-desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
</flux:sidebar>
<!-- Mobile User Menu -->
<flux:header class="lg:hidden">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<flux:spacer />
<flux:dropdown position="top" align="end">
<flux:profile
:initials="auth()->user()->initials()"
icon-trailing="chevron-down"
/>
<flux:menu>
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
</div>
</flux:menu.radio.group>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
</flux:menu.radio.group>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
</flux:header>
{{ $slot }}
@fluxScripts
</body>
</html>

View file

@ -1,3 +1,3 @@
<x-layouts::auth.card>
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.card>
</x-layouts::auth.simple>

View file

@ -6,7 +6,7 @@
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-md flex-col gap-6">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
@ -15,7 +15,7 @@
</a>
<div class="flex flex-col gap-6">
<div class="styled-container">
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<div class="px-10 py-8">{{ $slot }}</div>
</div>
</div>

View file

@ -6,7 +6,7 @@
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-2">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>

View file

@ -5,11 +5,11 @@
</head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-r dark:border-neutral-800">
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
<div class="absolute inset-0 bg-neutral-900"></div>
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium">
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
<span class="flex h-10 w-10 items-center justify-center rounded-md">
<x-app-logo-icon class="mr-2 h-7 fill-current text-white" />
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
</span>
{{ config('app.name', 'Laravel') }}
</a>
@ -20,14 +20,14 @@
<div class="relative z-20 mt-auto">
<blockquote class="space-y-2">
<p class="text-lg">&ldquo;{{ trim($message) }}&rdquo;</p>
<footer class="text-sm">{{ trim($author) }}</footer>
<flux:heading size="lg">&ldquo;{{ trim($message) }}&rdquo;</flux:heading>
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer>
</blockquote>
</div>
</div>
<div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>

View file

@ -1,62 +0,0 @@
<?php
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
new #[Layout('layouts.auth')] class extends Component
{
public string $password = '';
/**
* Confirm the current user's password.
*/
public function confirmPassword(): void
{
$this->validate([
'password' => ['required', 'string'],
]);
if (! Auth::guard('web')->validate([
'email' => Auth::user()->email,
'password' => $this->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
session(['auth.password_confirmed_at' => time()]);
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header
title="Confirm password"
description="This is a secure area of the application. Please confirm your password before continuing."
/>
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="confirmPassword" class="flex flex-col gap-6">
<!-- Password -->
<div class="grid gap-2">
<flux:input
wire:model="password"
id="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="new-password"
placeholder="Password"
/>
</div>
<flux:button variant="primary" type="submit" class="w-full">{{ __('Confirm') }}</flux:button>
</form>
</div>

View file

@ -1,45 +0,0 @@
<?php
use Illuminate\Support\Facades\Password;
use Livewire\Attributes\Layout;
use Livewire\Component;
new #[Layout('layouts.auth')] class extends Component
{
public string $email = '';
/**
* Send a password reset link to the provided email address.
*/
public function sendPasswordResetLink(): void
{
$this->validate([
'email' => ['required', 'string', 'email'],
]);
Password::sendResetLink($this->only('email'));
session()->flash('status', __('A reset link will be sent if the account exists.'));
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Forgot password" description="Enter your email to receive a password reset link" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="sendPasswordResetLink" class="flex flex-col gap-6">
<!-- Email Address -->
<div class="grid gap-2">
<flux:input wire:model="email" label="{{ __('Email Address') }}" type="email" name="email" required autofocus placeholder="email@example.com" />
</div>
<flux:button variant="primary" type="submit" class="w-full">{{ __('Email password reset link') }}</flux:button>
</form>
<div class="space-x-1 text-center text-sm text-zinc-400">
Or, return to
<x-text-link href="{{ route('login') }}">log in</x-text-link>
</div>
</div>

View file

@ -1,153 +0,0 @@
<?php
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
new #[Layout('layouts.auth')] class extends Component
{
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
public bool $remember = true;
/**
* Handle an incoming authentication request.
*/
public function login(): void
{
$this->validate();
$this->ensureIsNotRateLimited();
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
Session::regenerate();
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}
/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout(request()));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
public function mount(): void
{
if (app()->isLocal()) {
$this->email = 'admin@example.com';
$this->password = 'admin@example.com';
}
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Log in to your account" description="Enter your email and password below to log in"/>
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')"/>
<form wire:submit="login" class="flex flex-col gap-6">
<!-- Email Address -->
<flux:input wire:model="email" label="{{ __('Email address') }}" type="email" name="email" required autofocus
autocomplete="email" placeholder="admin@example.com"/>
<!-- Password -->
<div class="relative">
<flux:input
wire:model="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="current-password"
placeholder="Password"
/>
@if (Route::has('password.request'))
<x-text-link class="absolute right-0 top-0" href="{{ route('password.request') }}">
{{ __('Forgot your password?') }}
</x-text-link>
@endif
</div>
<!-- Remember Me -->
<flux:checkbox wire:model="remember" label="{{ __('Remember me') }}"/>
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full">{{ __('Log in') }}</flux:button>
</div>
</form>
@if (config('services.oidc.enabled'))
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-zinc-300 dark:border-zinc-600"></span>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white dark:bg-zinc-900 px-2 text-zinc-500 dark:text-zinc-400">
{{ __('Or') }}
</span>
</div>
</div>
<div class="flex items-center justify-end">
<flux:button
variant="outline"
type="button"
class="w-full"
href="{{ route('auth.oidc.redirect') }}"
>
{{ __('Continue with OIDC') }}
</flux:button>
</div>
@endif
@if (Route::has('register'))
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
Don't have an account?
<x-text-link href="{{ route('register') }}">Sign up</x-text-link>
</div>
@endif
</div>

View file

@ -1,98 +0,0 @@
<?php
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Component;
new #[Layout('layouts.auth')] class extends Component
{
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Handle an incoming registration request.
*/
public function register(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
]);
$validated['password'] = Hash::make($validated['password']);
event(new Registered(($user = User::create($validated))));
Auth::login($user);
$this->redirect(route('dashboard', absolute: false), navigate: true);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Create an account" description="Enter your details below to create your account" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="register" class="flex flex-col gap-6">
<!-- Name -->
<div class="grid gap-2">
<flux:input wire:model="name" id="name" label="{{ __('Name') }}" type="text" name="name" required autofocus autocomplete="name" placeholder="Full name" />
</div>
<!-- Email Address -->
<div class="grid gap-2">
<flux:input wire:model="email" id="email" label="{{ __('Email address') }}" type="email" name="email" required autocomplete="email" placeholder="email@example.com" />
</div>
<!-- Password -->
<div class="grid gap-2">
<flux:input
wire:model="password"
id="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="new-password"
placeholder="Password"
/>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<flux:input
wire:model="password_confirmation"
id="password_confirmation"
label="{{ __('Confirm password') }}"
type="password"
name="password_confirmation"
required
autocomplete="new-password"
placeholder="Confirm password"
/>
</div>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Create account') }}
</flux:button>
</div>
</form>
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
Already have an account?
<x-text-link href="{{ route('login') }}">Log in</x-text-link>
</div>
</div>

View file

@ -1,121 +0,0 @@
<?php
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Component;
new #[Layout('layouts.auth')] class extends Component
{
#[Locked]
public string $token = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Mount the component.
*/
public function mount(string $token): void
{
$this->token = $token;
$this->email = request()->string('email');
}
/**
* Reset the password for the given user.
*/
public function resetPassword(): void
{
$this->validate([
'token' => ['required'],
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$this->only('email', 'password', 'password_confirmation', 'token'),
function ($user) {
$user->forceFill([
'password' => Hash::make($this->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status !== Password::PasswordReset) {
$this->addError('email', __($status));
return;
}
Session::flash('status', __($status));
$this->redirectRoute('login', navigate: true);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Reset password" description="Please enter your new password below" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="resetPassword" class="flex flex-col gap-6">
<!-- Email Address -->
<div class="grid gap-2">
<flux:input wire:model="email" id="email" label="{{ __('Email') }}" type="email" name="email" required autocomplete="email" />
</div>
<!-- Password -->
<div class="grid gap-2">
<flux:input
wire:model="password"
id="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="new-password"
placeholder="Password"
/>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<flux:input
wire:model="password_confirmation"
id="password_confirmation"
label="{{ __('Confirm password') }}"
type="password"
name="password_confirmation"
required
autocomplete="new-password"
placeholder="Confirm password"
/>
</div>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Reset password') }}
</flux:button>
</div>
</form>
</div>

View file

@ -1,62 +0,0 @@
<?php
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Layout;
use Livewire\Component;
new #[Layout('layouts.auth')] class extends Component
{
/**
* Send an email verification notification to the user.
*/
public function sendVerification(): void
{
if (Auth::user()->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
return;
}
Auth::user()->sendEmailVerificationNotification();
Session::flash('status', 'verification-link-sent');
}
/**
* Log the current user out of the application.
*/
public function logout(Logout $logout): void
{
$logout();
$this->redirect('/', navigate: true);
}
}; ?>
<div class="mt-4 flex flex-col gap-6">
<div class="text-center text-sm text-gray-600">
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
</div>
@if (session('status') == 'verification-link-sent')
<div class="font-medium text-center text-sm text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</div>
@endif
<div class="flex flex-col items-center justify-between space-y-3">
<flux:button wire:click="sendVerification" variant="primary" class="w-full">
{{ __('Resend verification email') }}
</flux:button>
<button
wire:click="logout"
type="submit"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{{ __('Log out') }}
</button>
</div>
</div>

View file

@ -1,20 +0,0 @@
<?php
use Livewire\Component;
new class extends Component
{
//
}; ?>
<div class="flex flex-col items-start">
@include('partials.settings-heading')
<x-settings.layout heading="Appearance" subheading="Update your account's appearance settings">
<flux:radio.group x-data variant="segmented" x-model="$flux.appearance">
<flux:radio value="light" icon="sun">Light</flux:radio>
<flux:radio value="dark" icon="moon">Dark</flux:radio>
<flux:radio value="system" icon="computer-desktop">System</flux:radio>
</flux:radio.group>
</x-settings.layout>
</div>

View file

@ -1,61 +0,0 @@
<?php
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
new class extends Component
{
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => ['required', 'string', 'current_password'],
]);
tap(Auth::user(), $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}; ?>
<section class="mt-10 space-y-6">
<div class="relative mb-5">
<flux:heading>{{ __('Delete Account') }}</flux:heading>
<flux:subheading>{{ __('Delete your account and all of its resources') }}</flux:subheading>
</div>
<flux:modal.trigger name="confirm-user-deletion">
<flux:button {{--variant="danger" --}} x-data="" x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')">
{{ __('Delete Account') }}
</flux:button>
</flux:modal.trigger>
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable class="max-w-lg space-y-6">
<form wire:submit="deleteUser">
<h2 class="text-lg font-medium text-gray-900">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<div class="mt-6">
<flux:input wire:model="password" id="password" label="{{ __('Password') }}" type="password" name="password" />
</div>
<div class="mt-6 flex justify-end space-x-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Cancel') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" type="submit">{{ __('Delete Account') }}</flux:button>
</div>
</form>
</flux:modal>
</section>

View file

@ -0,0 +1,28 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header
:title="__('Confirm password')"
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
/>
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
@csrf
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="current-password"
:placeholder="__('Password')"
viewable
/>
<flux:button variant="primary" type="submit" class="w-full" data-test="confirm-password-button">
{{ __('Confirm') }}
</flux:button>
</form>
</div>
</x-layouts::auth>

View file

@ -0,0 +1,31 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-6">
@csrf
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email Address')"
type="email"
required
autofocus
placeholder="email@example.com"
/>
<flux:button variant="primary" type="submit" class="w-full" data-test="email-password-reset-link-button">
{{ __('Email password reset link') }}
</flux:button>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-400">
<span>{{ __('Or, return to') }}</span>
<flux:link :href="route('login')" wire:navigate>{{ __('log in') }}</flux:link>
</div>
</div>
</x-layouts::auth>

View file

@ -0,0 +1,85 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
@csrf
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email address')"
:value="old('email') || app()->isLocal() ? 'admin@example.com' : old('email')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
/>
<!-- Password -->
<div class="relative">
<flux:input
name="password"
:label="__('Password')"
:value="app()->isLocal() ? 'admin@example.com' : ''"
type="password"
required
autocomplete="current-password"
:placeholder="__('Password')"
viewable
/>
@if (Route::has('password.request'))
<x-text-link class="absolute top-0 text-sm end-0" :href="route('password.request')" wire:navigate>
{{ __('Forgot your password?') }}
</x-text-link>
@endif
</div>
<!-- Remember Me -->
<flux:checkbox name="remember" :label="__('Remember me')" :checked="old('remember')" />
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full" data-test="login-button">
{{ __('Log in') }}
</flux:button>
</div>
</form>
@if (Route::has('register'))
<div class="space-x-1 text-sm text-center rtl:space-x-reverse text-zinc-600 dark:text-zinc-400">
<span>{{ __('Don\'t have an account?') }}</span>
<flux:link :href="route('register')" wire:navigate>{{ __('Sign up') }}</flux:link>
</div>
@endif
@if (config('services.oidc.enabled'))
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-zinc-300 dark:border-zinc-600"></span>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white dark:bg-zinc-900 px-2 text-zinc-500 dark:text-zinc-400">
{{ __('Or') }}
</span>
</div>
</div>
<div class="flex items-center justify-end">
<flux:button
variant="outline"
type="button"
class="w-full"
href="{{ route('auth.oidc.redirect') }}"
>
{{ __('Continue with OIDC') }}
</flux:button>
</div>
@endif
</div>
</x-layouts::auth>

View file

@ -0,0 +1,67 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
@csrf
<!-- Name -->
<flux:input
name="name"
:label="__('Name')"
:value="old('name')"
type="text"
required
autofocus
autocomplete="name"
:placeholder="__('Full name')"
/>
<!-- Email Address -->
<flux:input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autocomplete="email"
placeholder="email@example.com"
/>
<!-- Password -->
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
viewable
/>
<!-- Confirm Password -->
<flux:input
name="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
viewable
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full" data-test="register-user-button">
{{ __('Create account') }}
</flux:button>
</div>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
<span>{{ __('Already have an account?') }}</span>
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
</div>
</div>
</x-layouts::auth>

View file

@ -0,0 +1,52 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-6">
@csrf
<!-- Token -->
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<!-- Email Address -->
<flux:input
name="email"
value="{{ request('email') }}"
:label="__('Email')"
type="email"
required
autocomplete="email"
/>
<!-- Password -->
<flux:input
name="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
viewable
/>
<!-- Confirm Password -->
<flux:input
name="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
viewable
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full" data-test="reset-password-button">
{{ __('Reset password') }}
</flux:button>
</div>
</form>
</div>
</x-layouts::auth>

View file

@ -0,0 +1,95 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<div
class="relative w-full h-auto"
x-cloak
x-data="{
showRecoveryInput: @js($errors->has('recovery_code')),
code: '',
recovery_code: '',
toggleInput() {
this.showRecoveryInput = !this.showRecoveryInput;
this.code = '';
this.recovery_code = '';
$dispatch('clear-2fa-auth-code');
$nextTick(() => {
this.showRecoveryInput
? this.$refs.recovery_code?.focus()
: $dispatch('focus-2fa-auth-code');
});
},
}"
>
<div x-show="!showRecoveryInput">
<x-auth-header
:title="__('Authentication Code')"
:description="__('Enter the authentication code provided by your authenticator application.')"
/>
</div>
<div x-show="showRecoveryInput">
<x-auth-header
:title="__('Recovery Code')"
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
/>
</div>
<form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<div class="space-y-5 text-center">
<div x-show="!showRecoveryInput">
<div class="flex items-center justify-center my-5">
<flux:otp
x-model="code"
length="6"
name="code"
label="OTP Code"
label:sr-only
class="mx-auto"
/>
</div>
</div>
<div x-show="showRecoveryInput">
<div class="my-5">
<flux:input
type="text"
name="recovery_code"
x-ref="recovery_code"
x-bind:required="showRecoveryInput"
autocomplete="one-time-code"
x-model="recovery_code"
/>
</div>
@error('recovery_code')
<flux:text color="red">
{{ $message }}
</flux:text>
@enderror
</div>
<flux:button
variant="primary"
type="submit"
class="w-full"
>
{{ __('Continue') }}
</flux:button>
</div>
<div class="mt-5 space-x-0.5 text-sm leading-5 text-center">
<span class="opacity-50">{{ __('or you can') }}</span>
<div class="inline font-medium underline cursor-pointer opacity-80">
<span x-show="!showRecoveryInput" @click="toggleInput()">{{ __('login using a recovery code') }}</span>
<span x-show="showRecoveryInput" @click="toggleInput()">{{ __('login using an authentication code') }}</span>
</div>
</div>
</form>
</div>
</div>
</x-layouts::auth>

View file

@ -0,0 +1,29 @@
<x-layouts::auth>
<div class="mt-4 flex flex-col gap-6">
<flux:text class="text-center">
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
</flux:text>
@if (session('status') == 'verification-link-sent')
<flux:text class="text-center font-medium !dark:text-green-400 !text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</flux:text>
@endif
<div class="flex flex-col items-center justify-between space-y-3">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Resend verification email') }}
</flux:button>
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<flux:button variant="ghost" type="submit" class="text-sm cursor-pointer" data-test="logout-button">
{{ __('Log out') }}
</flux:button>
</form>
</div>
</div>
</x-layouts::auth>

View file

@ -0,0 +1,22 @@
<?php
use Livewire\Component;
new class extends Component
{
//
}; ?>
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Appearance Settings') }}</flux:heading>
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
<flux:radio.group x-data variant="segmented" x-model="$flux.appearance">
<flux:radio value="light" icon="sun">{{ __('Light') }}</flux:radio>
<flux:radio value="dark" icon="moon">{{ __('Dark') }}</flux:radio>
<flux:radio value="system" icon="computer-desktop">{{ __('System') }}</flux:radio>
</flux:radio.group>
</x-pages::settings.layout>
</section>

View file

@ -0,0 +1,64 @@
<?php
use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
new class extends Component
{
use PasswordValidationRules;
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => $this->currentPasswordRules(),
]);
tap(Auth::user(), $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}; ?>
<section class="mt-10 space-y-6">
<div class="relative mb-5">
<flux:heading>{{ __('Delete account') }}</flux:heading>
<flux:subheading>{{ __('Delete your account and all of its resources') }}</flux:subheading>
</div>
<flux:modal.trigger name="confirm-user-deletion">
<flux:button x-data="" x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')" data-test="delete-user-button">
{{ __('Delete account') }}
</flux:button>
</flux:modal.trigger>
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable class="max-w-lg">
<form method="POST" wire:submit="deleteUser" class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Are you sure you want to delete your account?') }}</flux:heading>
<flux:subheading>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</flux:subheading>
</div>
<flux:input wire:model="password" :label="__('Password')" type="password" />
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Cancel') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" type="submit" data-test="confirm-delete-user-button">
{{ __('Delete account') }}
</flux:button>
</div>
</form>
</flux:modal>
</section>

View file

@ -0,0 +1,27 @@
<div class="flex items-start max-md:flex-col">
<div class="me-10 w-full pb-4 md:w-[220px]">
<flux:navlist aria-label="{{ __('Settings') }}">
<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>
@endif
@if (Laravel\Fortify\Features::canManageTwoFactorAuthentication() && auth()?->user()?->oidc_sub === null)
<flux:navlist.item :href="route('two-factor.show')" wire:navigate>{{ __('2FA') }}</flux:navlist.item>
@endif
<flux:navlist.item :href="route('settings.support')" wire:navigate>{{ __('Support') }}</flux:navlist.item>
</flux:navlist>
</div>
<flux:separator class="md:hidden" />
<div class="flex-1 self-stretch max-md:pt-6">
<flux:heading>{{ $heading ?? '' }}</flux:heading>
<flux:subheading>{{ $subheading ?? '' }}</flux:subheading>
<div class="mt-5 w-full max-w-lg">
{{ $slot }}
</div>
</div>
</div>

View file

@ -1,13 +1,15 @@
<?php
use App\Concerns\PasswordValidationRules;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
new class extends Component
{
use PasswordValidationRules;
public string $current_password = '';
public string $password = '';
@ -21,8 +23,8 @@ new class extends Component
{
try {
$validated = $this->validate([
'current_password' => ['required', 'string', 'current_password'],
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
@ -31,7 +33,7 @@ new class extends Component
}
Auth::user()->update([
'password' => Hash::make($validated['password']),
'password' => $validated['password'],
]);
$this->reset('current_password', 'password', 'password_confirmation');
@ -43,39 +45,37 @@ new class extends Component
<section class="w-full">
@include('partials.settings-heading')
<x-settings.layout heading="Update password" subheading="Ensure your account is using a long, random password to stay secure">
<form wire:submit="updatePassword" class="mt-6 space-y-6">
<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"
id="update_password_current_passwordpassword"
label="{{ __('Current password') }}"
:label="__('Current password')"
type="password"
name="current_password"
required
autocomplete="current-password"
/>
<flux:input
wire:model="password"
id="update_password_password"
label="{{ __('New password') }}"
:label="__('New password')"
type="password"
name="password"
required
autocomplete="new-password"
/>
<flux:input
wire:model="password_confirmation"
id="update_password_password_confirmation"
label="{{ __('Confirm Password') }}"
:label="__('Confirm Password')"
type="password"
name="password_confirmation"
required
autocomplete="new-password"
/>
<div class="flex items-center gap-4">
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full">{{ __('Save') }}</flux:button>
<flux:button variant="primary" type="submit" class="w-full" data-test="update-password-button">
{{ __('Save') }}
</flux:button>
</div>
<x-action-message class="me-3" on="password-updated">
@ -83,5 +83,5 @@ new class extends Component
</x-action-message>
</div>
</form>
</x-settings.layout>
</x-pages::settings.layout>
</section>

View file

@ -42,7 +42,7 @@ new class extends Component
<section class="w-full">
@include('partials.settings-heading')
<x-settings.layout heading="Preferences" subheading="Update your preferences">
<x-pages::settings.layout heading="Preferences" subheading="Update your preferences">
<form wire:submit="updatePreferences" class="my-6 w-full space-y-6">
<flux:select wire:model="timezone" label="Timezone">
@ -72,5 +72,5 @@ new class extends Component
</div>
</form>
</x-settings.layout>
</x-pages::settings.layout>
</section>

View file

@ -1,13 +1,17 @@
<?php
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Component;
new class extends Component
{
use ProfileValidationRules;
public string $name = '';
public string $email = '';
@ -28,18 +32,7 @@ new class extends Component
{
$user = Auth::user();
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($user->id),
],
]);
$validated = $this->validate($this->profileRules($user->id));
$user->fill($validated);
@ -69,35 +62,47 @@ new class extends Component
Session::flash('status', 'verification-link-sent');
}
#[Computed]
public function hasUnverifiedEmail(): bool
{
return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
}
#[Computed]
public function showDeleteUser(): bool
{
return ! Auth::user() instanceof MustVerifyEmail
|| (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
}
}; ?>
<section class="w-full">
@include('partials.settings-heading')
<x-settings.layout heading="Profile" subheading="Update your name and email address">
<flux:heading class="sr-only">{{ __('Profile Settings') }}</flux:heading>
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<form wire:submit="updateProfileInformation" class="my-6 w-full space-y-6">
<flux:input wire:model="name" label="{{ __('Name') }}" type="text" name="name" required autofocus autocomplete="name" />
<flux:input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" />
<div>
<flux:input wire:model="email" label="{{ __('Email') }}" type="email" name="email" required autocomplete="email" />
<flux:input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" />
@if (auth()->user() instanceof \Illuminate\Contracts\Auth\MustVerifyEmail &&! auth()->user()->hasVerifiedEmail())
@if ($this->hasUnverifiedEmail)
<div>
<p class="mt-2 text-sm text-gray-800">
<flux:text class="mt-4">
{{ __('Your email address is unverified.') }}
<button
wire:click.prevent="resendVerificationNotification"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<flux:link class="text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
</flux:link>
</flux:text>
@if (session('status') === 'verification-link-sent')
<p class="mt-2 text-sm font-medium text-green-600">
<flux:text class="mt-2 font-medium !dark:text-green-400 !text-green-600">
{{ __('A new verification link has been sent to your email address.') }}
</p>
</flux:text>
@endif
</div>
@endif
@ -105,7 +110,9 @@ new class extends Component
<div class="flex items-center gap-4">
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full">{{ __('Save') }}</flux:button>
<flux:button variant="primary" type="submit" class="w-full" data-test="update-profile-button">
{{ __('Save') }}
</flux:button>
</div>
<x-action-message class="me-3" on="profile-updated">
@ -114,6 +121,8 @@ new class extends Component
</div>
</form>
<livewire:settings.delete-user-form />
</x-settings.layout>
@if ($this->showDeleteUser)
<livewire:pages::settings.delete-user-form />
@endif
</x-pages::settings.layout>
</section>

View file

@ -7,7 +7,7 @@ new class extends Component {}
<section class="w-full">
@include('partials.settings-heading')
<x-settings.layout heading="Support" subheading="Support the development of this project">
<x-pages::settings.layout heading="Support" subheading="Support the development of this project">
<div class="flex items-center gap-4">
<div class="flex items-center justify-end gap-2">
@ -35,5 +35,5 @@ new class extends Component {}
</div>
</div>
</x-settings.layout>
</x-pages::settings.layout>
</section>

View file

@ -0,0 +1,388 @@
<?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\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Symfony\Component\HttpFoundation\Response;
new class extends Component
{
#[Locked]
public bool $twoFactorEnabled;
#[Locked]
public bool $requiresConfirmation;
#[Locked]
public string $qrCodeSvg = '';
#[Locked]
public string $manualSetupKey = '';
public bool $showModal = false;
public bool $showVerificationStep = false;
#[Validate('required|string|size:6', onUpdate: false)]
public string $code = '';
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): 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');
}
/**
* Enable two-factor authentication for the user.
*/
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
{
$enableTwoFactorAuthentication(auth()->user());
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
$this->loadSetupData();
$this->showModal = true;
}
/**
* Load the two-factor authentication setup data for the user.
*/
private function loadSetupData(): void
{
$user = auth()->user();
try {
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
$this->reset('qrCodeSvg', 'manualSetupKey');
}
}
/**
* Show the two-factor verification step if necessary.
*/
public function showVerificationIfNecessary(): void
{
if ($this->requiresConfirmation) {
$this->showVerificationStep = true;
$this->resetErrorBag();
return;
}
$this->closeModal();
}
/**
* Confirm two-factor authentication for the user.
*/
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
{
$this->validate();
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
$this->closeModal();
$this->twoFactorEnabled = true;
}
/**
* Reset two-factor verification state.
*/
public function resetVerification(): void
{
$this->reset('code', 'showVerificationStep');
$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.
*/
public function closeModal(): void
{
$this->reset(
'code',
'manualSetupKey',
'qrCodeSvg',
'showModal',
'showVerificationStep',
);
$this->resetErrorBag();
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
}
/**
* Get the current modal configuration state.
*/
public function getModalConfigProperty(): array
{
if ($this->twoFactorEnabled) {
return [
'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'),
];
}
if ($this->showVerificationStep) {
return [
'title' => __('Verify Authentication Code'),
'description' => __('Enter the 6-digit code from your authenticator app.'),
'buttonText' => __('Continue'),
];
}
return [
'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">
@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"
>
<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">
<div class="p-2.5 rounded-full border border-stone-200 dark:border-stone-600 overflow-hidden bg-stone-100 dark:bg-stone-200 relative">
<div class="flex items-stretch absolute inset-0 w-full h-full divide-x [&>div]:flex-1 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<div class="flex flex-col items-stretch absolute w-full h-full divide-y [&>div]:flex-1 inset-0 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<flux:icon.qr-code class="relative z-20 dark:text-accent-foreground"/>
</div>
</div>
<div class="space-y-2 text-center">
<flux:heading size="lg">{{ $this->modalConfig['title'] }}</flux:heading>
<flux:text>{{ $this->modalConfig['description'] }}</flux:text>
</div>
</div>
@if ($showVerificationStep)
<div class="space-y-6">
<div class="flex flex-col items-center space-y-3 justify-center">
<flux:otp
name="code"
wire:model="code"
length="6"
label="OTP Code"
label:sr-only
class="mx-auto"
/>
</div>
<div class="flex items-center space-x-3">
<flux:button
variant="outline"
class="flex-1"
wire:click="resetVerification"
>
{{ __('Back') }}
</flux:button>
<flux:button
variant="primary"
class="flex-1"
wire:click="confirmTwoFactor"
x-bind:disabled="$wire.code.length < 6"
>
{{ __('Confirm') }}
</flux:button>
</div>
</div>
@else
@error('setupData')
<flux:callout variant="danger" icon="x-circle" heading="{{ $message }}"/>
@enderror
<div class="flex justify-center">
<div class="relative w-64 overflow-hidden border rounded-lg border-stone-200 dark:border-stone-700 aspect-square">
@empty($qrCodeSvg)
<div class="absolute inset-0 flex items-center justify-center bg-white dark:bg-stone-700 animate-pulse">
<flux:icon.loading/>
</div>
@else
<div x-data class="flex items-center justify-center h-full p-4">
<div
class="bg-white p-3 rounded"
:style="($flux.appearance === 'dark' || ($flux.appearance === 'system' && $flux.dark)) ? 'filter: invert(1) brightness(1.5)' : ''"
>
{!! $qrCodeSvg !!}
</div>
</div>
@endempty
</div>
</div>
<div>
<flux:button
:disabled="$errors->has('setupData')"
variant="primary"
class="w-full"
wire:click="showVerificationIfNecessary"
>
{{ $this->modalConfig['buttonText'] }}
</flux:button>
</div>
<div class="space-y-4">
<div class="relative flex items-center justify-center w-full">
<div class="absolute inset-0 w-full h-px top-1/2 bg-stone-200 dark:bg-stone-600"></div>
<span class="relative px-2 text-sm bg-white dark:bg-stone-800 text-stone-600 dark:text-stone-400">
{{ __('or, enter the code manually') }}
</span>
</div>
<div
class="flex items-center space-x-2"
x-data="{
copied: false,
async copy() {
try {
await navigator.clipboard.writeText('{{ $manualSetupKey }}');
this.copied = true;
setTimeout(() => this.copied = false, 1500);
} catch (e) {
console.warn('Could not copy to clipboard');
}
}
}"
>
<div class="flex items-stretch w-full border rounded-xl dark:border-stone-700">
@empty($manualSetupKey)
<div class="flex items-center justify-center w-full p-3 bg-stone-100 dark:bg-stone-700">
<flux:icon.loading variant="mini"/>
</div>
@else
<input
type="text"
readonly
value="{{ $manualSetupKey }}"
class="w-full p-3 bg-transparent outline-none text-stone-900 dark:text-stone-100"
/>
<button
@click="copy()"
class="px-3 transition-colors border-l cursor-pointer border-stone-200 dark:border-stone-600"
>
<flux:icon.document-duplicate x-show="!copied" variant="outline"></flux:icon>
<flux:icon.check
x-show="copied"
variant="solid"
class="text-green-500"
></flux:icon>
</button>
@endempty
</div>
</div>
</div>
@endif
</div>
</flux:modal>
</section>

View file

@ -0,0 +1,136 @@
<?php
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Locked;
use Livewire\Component;
new class extends Component
{
#[Locked]
public array $recoveryCodes = [];
/**
* Mount the component.
*/
public function mount(): void
{
$this->loadRecoveryCodes();
}
/**
* Generate new recovery codes for the user.
*/
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
{
$generateNewRecoveryCodes(auth()->user());
$this->loadRecoveryCodes();
}
/**
* Load the recovery codes for the user.
*/
private function loadRecoveryCodes(): void
{
$user = auth()->user();
if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
try {
$this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
} catch (Exception) {
$this->addError('recoveryCodes', 'Failed to load recovery codes');
$this->recoveryCodes = [];
}
}
}
}; ?>
<div
class="py-6 space-y-6 border shadow-sm rounded-xl border-zinc-200 dark:border-white/10"
wire:cloak
x-data="{ showRecoveryCodes: false }"
>
<div class="px-6 space-y-2">
<div class="flex items-center gap-2">
<flux:icon.lock-closed variant="outline" class="size-4"/>
<flux:heading size="lg" level="3">{{ __('2FA Recovery Codes') }}</flux:heading>
</div>
<flux:text variant="subtle">
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
</flux:text>
</div>
<div class="px-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<flux:button
x-show="!showRecoveryCodes"
icon="eye"
icon:variant="outline"
@click="showRecoveryCodes = true;"
aria-expanded="false"
aria-controls="recovery-codes-section"
>
{{ __('View Recovery Codes') }}
</flux:button>
<flux:button
x-show="showRecoveryCodes"
icon="eye-slash"
icon:variant="outline"
variant="primary"
@click="showRecoveryCodes = false"
aria-expanded="true"
aria-controls="recovery-codes-section"
>
{{ __('Hide Recovery Codes') }}
</flux:button>
@if (filled($recoveryCodes))
<flux:button
x-show="showRecoveryCodes"
icon="arrow-path"
variant="filled"
wire:click="regenerateRecoveryCodes"
>
{{ __('Regenerate Codes') }}
</flux:button>
@endif
</div>
<div
x-show="showRecoveryCodes"
x-transition
id="recovery-codes-section"
class="relative overflow-hidden"
x-bind:aria-hidden="!showRecoveryCodes"
>
<div class="mt-3 space-y-3">
@error('recoveryCodes')
<flux:callout variant="danger" icon="x-circle" heading="{{$message}}"/>
@enderror
@if (filled($recoveryCodes))
<div
class="grid gap-1 p-4 font-mono text-sm rounded-lg bg-zinc-100 dark:bg-white/5"
role="list"
aria-label="{{ __('Recovery codes') }}"
>
@foreach($recoveryCodes as $code)
<div
role="listitem"
class="select-text"
wire:loading.class="opacity-50 animate-pulse"
>
{{ $code }}
</div>
@endforeach
</div>
<flux:text variant="subtle" class="text-xs">
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
</flux:text>
@endif
</div>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<div class="relative mb-6 w-full">
<flux:heading size="xl" level="1">Settings</flux:heading>
<flux:subheading size="lg" class="mb-6">Manage your profile and account settings</flux:subheading>
<flux:heading size="xl" level="1">{{ __('Settings') }}</flux:heading>
<flux:subheading size="lg" class="mb-6">{{ __('Manage your profile and account settings') }}</flux:subheading>
<flux:separator variant="subtle" />
</div>