feat(profile): align settings with dashboard layout

This commit is contained in:
2026-07-31 03:41:51 +02:00
parent e47243f7dc
commit 73c601ddc2
18 changed files with 947 additions and 377 deletions

View File

@@ -65,7 +65,7 @@ class TwoFactorSettingsController extends Controller
$redirect = $request->routeIs('two-factor.setup.confirm')
? redirect()->route('two-factor.setup')
: redirect()->route('profile.show');
: redirect()->route('profile.security');
return $redirect
->with('success', 'Two-factor authentication is now enabled.')
@@ -102,7 +102,7 @@ class TwoFactorSettingsController extends Controller
));
$securityEvents->record('two_factor.disabled', $user, $request);
return redirect()->route('profile.show')
return redirect()->route('profile.security')
->with('success', 'Two-factor authentication has been disabled.');
}
@@ -138,7 +138,7 @@ class TwoFactorSettingsController extends Controller
$securityEvents->record('two_factor.recovery_codes_revealed', $user, $request);
}
return redirect()->route('profile.show')
return redirect()->route('profile.security')
->with('success', $request->boolean('regenerate')
? 'New recovery codes have been generated. Previous codes no longer work.'
: 'Recovery codes revealed.')
@@ -158,7 +158,7 @@ class TwoFactorSettingsController extends Controller
{
return $request->routeIs('two-factor.setup.*')
? redirect()->route('two-factor.setup')
: redirect()->route('profile.show');
: redirect()->route('profile.security');
}
private function validatePasswordForPasswordUser(Request $request, User $user): void

View File

@@ -2,26 +2,50 @@
namespace App\Http\Controllers;
use App\Http\Requests\Profile\UpdateProfileRequest;
use App\Models\User;
use App\Services\Auth\TwoFactorEnrollmentState;
use Illuminate\Http\Request;
use App\Services\Auth\SecurityEventRecorder;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Display the authenticated user's profile and security settings.
* Display the authenticated user's profile settings.
*/
public function __invoke(
Request $request,
TwoFactorEnrollmentState $enrollmentState
): Response {
public function show(): Response
{
return Inertia::render('Profile/Show');
}
/**
* Update the authenticated user's profile settings.
*/
public function update(
UpdateProfileRequest $request,
SecurityEventRecorder $securityEvents
): RedirectResponse {
/** @var User $user */
$user = $request->user();
$emailChanged = $user->email !== $request->validated('email');
return Inertia::render('Profile/Show', [
'twoFactor' => $enrollmentState->for($user),
$user->fill($request->validated());
if ($emailChanged) {
$user->email_verified_at = null;
}
$user->save();
$securityEvents->record('profile.updated', $user, $request, [
'email_changed' => $emailChanged,
]);
if ($emailChanged) {
$user->sendEmailVerificationNotification();
}
return redirect()->route('profile.show')
->with('success', 'Your profile has been updated.');
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Profile\UpdatePasswordRequest;
use App\Models\User;
use App\Notifications\PasswordChangedNotification;
use App\Services\Auth\SecurityEventRecorder;
use App\Services\Auth\TwoFactorEnrollmentState;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class ProfileSecurityController extends Controller
{
/**
* Display the authenticated user's security settings.
*/
public function show(
Request $request,
TwoFactorEnrollmentState $enrollmentState
): Response {
/** @var User $user */
$user = $request->user();
return Inertia::render('Profile/Security', [
'twoFactor' => $enrollmentState->for($user),
]);
}
/**
* Update the authenticated user's password and revoke other sessions.
*/
public function updatePassword(
UpdatePasswordRequest $request,
SecurityEventRecorder $securityEvents
): RedirectResponse {
/** @var User $user */
$user = $request->user();
$user->forceFill([
'password' => $request->validated('password'),
'remember_token' => Str::random(60),
'auth_session_version' => $user->auth_session_version + 1,
])->save();
if (config('session.driver') === 'database') {
DB::table(config('session.table'))
->where('user_id', $user->getKey())
->where('id', '!=', $request->session()->getId())
->delete();
}
$request->session()->put(
'auth.session_version',
$user->auth_session_version
);
$user->notify(new PasswordChangedNotification(
(string) $request->ip(),
now()->toIso8601String()
));
$securityEvents->record('password.changed', $user, $request);
return redirect()->route('profile.security')
->with('success', 'Your password has been updated. Other sessions were signed out.');
}
}

View File

@@ -52,7 +52,7 @@ class AddSecurityHeaders
'login',
'password.*',
'two-factor.*',
'profile.show',
'profile.*',
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Profile;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules;
class UpdatePasswordRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
/** @var User $user */
$user = $this->user();
return [
'current_password' => $user->hasPassword()
? ['required', 'string', 'current_password:web']
: ['nullable', 'string'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\Profile;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'username' => [
'required',
'string',
'max:255',
'alpha_dash',
function ($attribute, $value, $fail): void {
if (User::whereKeyNot($this->user()->getKey())
->whereRaw('LOWER(username) = ?', [strtolower($value)])
->exists()) {
$fail('The username has already been taken.');
}
},
],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->getKey()),
],
];
}
}

View File

@@ -35,6 +35,6 @@ class TwoFactorFailedNotification extends Notification implements ShouldQueue
->line("IP address: {$this->ipAddress}")
->line("Browser: {$this->userAgent}")
->line('If this was not you, change your password immediately.')
->action('Review account security', url('/profile'));
->action('Review account security', url('/profile/security'));
}
}

View File

@@ -34,6 +34,6 @@ class TwoFactorSecurityNotification extends Notification implements ShouldQueue
->line("Time: {$this->occurredAt}")
->line("IP address: {$this->ipAddress}")
->line('If you did not perform this action, change your password and contact support immediately.')
->action('Review account security', url('/profile'));
->action('Review account security', url('/profile/security'));
}
}

View File

@@ -1,6 +1,9 @@
import antfu from '@antfu/eslint-config'
export default antfu({
ignores: [
'storage/inertia-devtools/**',
],
vue: true,
typescript: true,
})

View File

@@ -12,7 +12,11 @@ const { user } = useAuth()
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar title="Dashboard" />
<UDashboardNavbar title="Dashboard">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>

View File

@@ -0,0 +1,388 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { computed } from 'vue'
import RecoveryCodesPanel from '@/components/auth/RecoveryCodesPanel.vue'
import ProfileSettingsPanel from '@/components/profile/ProfileSettingsPanel.vue'
import { useAuth } from '@/composables/useAuth'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
interface TwoFactorState {
available: boolean
enabled: boolean
pending: boolean
requiresPassword: boolean
qrCodeDataUri: string | null
secretKey: string | null
}
defineOptions({
layout: DashboardLayout,
})
const props = defineProps<{
twoFactor: TwoFactorState
}>()
const { config, flash } = useAuth()
const enableForm = useForm({ password: '' })
const confirmForm = useForm({ code: '' })
const disableForm = useForm({ password: '', code: '' })
const recoveryForm = useForm({ password: '', code: '', regenerate: false })
const passwordForm = useForm({
current_password: '',
password: '',
password_confirmation: '',
})
const recoveryCodes = computed(() => flash.value.recoveryCodes ?? [])
const twoFactorRequired = computed(() =>
config.value.features.two_factor && config.value.features.two_factor_required,
)
function enable() {
enableForm.post('/profile/two-factor', {
preserveScroll: true,
onFinish: () => enableForm.reset(),
})
}
function confirm() {
confirmForm.post('/profile/two-factor/confirm', {
preserveScroll: true,
onFinish: () => confirmForm.reset(),
})
}
function disable() {
disableForm.delete('/profile/two-factor', {
preserveScroll: true,
onFinish: () => disableForm.reset(),
})
}
function recoveryCodesAction(regenerate: boolean) {
recoveryForm.regenerate = regenerate
recoveryForm.post('/profile/two-factor/recovery-codes', {
preserveScroll: true,
onFinish: () => recoveryForm.reset(),
})
}
function updatePassword() {
passwordForm.put('/profile/password', {
preserveScroll: true,
onSuccess: () => passwordForm.reset(),
onFinish: () => passwordForm.reset(
'current_password',
'password',
'password_confirmation',
),
})
}
</script>
<template>
<ProfileSettingsPanel>
<UAlert
v-if="flash.success"
color="success"
icon="i-lucide-check-circle"
:title="flash.success"
/>
<UAlert
v-if="twoFactorRequired && !twoFactor.enabled"
color="warning"
icon="i-lucide-shield-alert"
title="Two-factor authentication is required"
description="Finish setup before accessing the rest of the application."
/>
<UPageCard
:title="props.twoFactor.requiresPassword ? 'Password' : 'Set a password'"
:description="props.twoFactor.requiresPassword
? 'Confirm your current password before setting a new one. Other sessions will be signed out.'
: 'Add a password so you can also sign in without your social provider.'"
variant="subtle"
>
<form class="flex max-w-sm flex-col gap-4" @submit.prevent="updatePassword">
<UFormField
v-if="props.twoFactor.requiresPassword"
label="Current password"
name="current_password"
:error="passwordForm.errors.current_password"
>
<UInput
v-model="passwordForm.current_password"
type="password"
autocomplete="current-password"
class="w-full"
/>
</UFormField>
<UFormField
label="New password"
name="password"
description="Use between 15 and 128 characters."
:error="passwordForm.errors.password"
>
<UInput
v-model="passwordForm.password"
type="password"
autocomplete="new-password"
class="w-full"
/>
</UFormField>
<UFormField
label="Confirm new password"
name="password_confirmation"
:error="passwordForm.errors.password_confirmation"
>
<UInput
v-model="passwordForm.password_confirmation"
type="password"
autocomplete="new-password"
class="w-full"
/>
</UFormField>
<UButton
type="submit"
label="Update password"
class="w-fit"
:loading="passwordForm.processing"
/>
</form>
</UPageCard>
<UPageCard variant="subtle">
<template #header>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold">
Two-factor authentication
</h2>
<p class="text-muted mt-1 text-sm">
Protect your account with a time-based code from an authenticator app.
</p>
</div>
<UBadge :color="twoFactor.enabled ? 'success' : twoFactor.pending ? 'warning' : 'neutral'">
{{ twoFactor.enabled ? 'Enabled' : twoFactor.pending ? 'Setup pending' : 'Disabled' }}
</UBadge>
</div>
</template>
<UAlert
v-if="!twoFactor.available"
color="neutral"
icon="i-lucide-shield-off"
title="Two-factor authentication is not available"
description="An administrator can enable this feature through the authentication configuration."
/>
<form v-else-if="!twoFactor.enabled && !twoFactor.pending" class="space-y-4" @submit.prevent="enable">
<UAlert
v-if="!twoFactor.requiresPassword"
color="info"
icon="i-lucide-info"
title="Your social login session will authorize enrollment."
/>
<UFormField
v-if="twoFactor.requiresPassword"
label="Current password"
name="password"
:error="enableForm.errors.password"
>
<UInput
v-model="enableForm.password"
type="password"
autocomplete="current-password"
class="w-full"
/>
</UFormField>
<UButton type="submit" :loading="enableForm.processing">
Enable two-factor authentication
</UButton>
</form>
<div v-else-if="twoFactor.pending" class="space-y-6">
<div class="grid gap-6 md:grid-cols-[auto_1fr]">
<img
v-if="twoFactor.qrCodeDataUri"
:src="twoFactor.qrCodeDataUri"
alt="Two-factor authenticator QR code"
class="size-48 rounded-lg border bg-white p-2"
>
<div class="space-y-3">
<h3 class="font-medium">
Scan this QR code
</h3>
<p class="text-muted text-sm">
Scan it with any TOTP-compatible authenticator app, then enter the generated code below.
</p>
<div>
<p class="text-muted mb-1 text-xs">
Manual setup key
</p>
<code class="block break-all rounded bg-elevated p-2 text-sm">{{ twoFactor.secretKey }}</code>
</div>
</div>
</div>
<form class="space-y-4" @submit.prevent="confirm">
<UFormField
label="Six-digit authentication code"
name="code"
:error="confirmForm.errors.code"
>
<UInput
v-model="confirmForm.code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="123456"
class="w-full"
/>
</UFormField>
<div class="flex flex-wrap gap-3">
<UButton type="submit" :loading="confirmForm.processing">
Confirm setup
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
:loading="disableForm.processing"
@click="disable"
>
Cancel setup
</UButton>
</div>
</form>
</div>
<div v-else class="space-y-6">
<UAlert
color="success"
variant="subtle"
icon="i-lucide-shield-check"
title="Your account requires a second factor when signing in."
/>
<div class="space-y-4 border-t pt-6">
<div>
<h3 class="font-medium">
Recovery codes
</h3>
<p class="text-muted text-sm">
Re-authenticate to reveal your codes or replace them with a new set.
</p>
</div>
<UFormField
v-if="twoFactor.requiresPassword"
label="Current password"
name="password"
:error="recoveryForm.errors.password"
>
<UInput
v-model="recoveryForm.password"
type="password"
autocomplete="current-password"
class="w-full"
/>
</UFormField>
<UFormField
label="Authentication or recovery code"
name="code"
:error="recoveryForm.errors.code"
>
<UInput
v-model="recoveryForm.code"
autocomplete="one-time-code"
class="w-full"
/>
</UFormField>
<div class="flex flex-wrap gap-3">
<UButton
variant="outline"
:loading="recoveryForm.processing && !recoveryForm.regenerate"
@click="recoveryCodesAction(false)"
>
Reveal recovery codes
</UButton>
<UButton
color="warning"
variant="outline"
:loading="recoveryForm.processing && recoveryForm.regenerate"
@click="recoveryCodesAction(true)"
>
Generate new codes
</UButton>
</div>
</div>
<UAlert
v-if="twoFactorRequired"
color="neutral"
variant="subtle"
icon="i-lucide-lock-keyhole"
title="Two-factor authentication is required by your administrator."
description="It cannot be disabled while the mandatory security policy is active."
/>
<form v-else class="space-y-4 border-t pt-6" @submit.prevent="disable">
<div>
<h3 class="font-medium text-error">
Disable two-factor authentication
</h3>
<p class="text-muted text-sm">
This removes the additional protection from your account.
</p>
</div>
<UFormField
v-if="twoFactor.requiresPassword"
label="Current password"
name="password"
:error="disableForm.errors.password"
>
<UInput
v-model="disableForm.password"
type="password"
autocomplete="current-password"
class="w-full"
/>
</UFormField>
<UFormField
label="Authentication or recovery code"
name="code"
:error="disableForm.errors.code"
>
<UInput
v-model="disableForm.code"
autocomplete="one-time-code"
class="w-full"
/>
</UFormField>
<UButton type="submit" color="error" :loading="disableForm.processing">
Disable two-factor authentication
</UButton>
</form>
</div>
</UPageCard>
<UPageCard v-if="recoveryCodes.length" variant="subtle">
<template #header>
<div>
<h2 class="font-semibold">
Save your recovery codes
</h2>
<p class="text-muted mt-1 text-sm">
Store these codes somewhere safe. Each code can be used once.
</p>
</div>
</template>
<RecoveryCodesPanel :codes="recoveryCodes" />
</UPageCard>
</ProfileSettingsPanel>
</template>

View File

@@ -1,346 +1,144 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { computed } from 'vue'
import RecoveryCodesPanel from '@/components/auth/RecoveryCodesPanel.vue'
import ProfileSettingsPanel from '@/components/profile/ProfileSettingsPanel.vue'
import { useAuth } from '@/composables/useAuth'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
interface TwoFactorState {
available: boolean
enabled: boolean
pending: boolean
requiresPassword: boolean
qrCodeDataUri: string | null
secretKey: string | null
}
defineOptions({
layout: DashboardLayout,
})
defineProps<{
twoFactor: TwoFactorState
}>()
const { flash, user } = useAuth()
const { config, flash, user } = useAuth()
const form = useForm({
first_name: user.value?.first_name ?? '',
last_name: user.value?.last_name ?? '',
username: user.value?.username ?? '',
email: user.value?.email ?? '',
})
const enableForm = useForm({ password: '' })
const confirmForm = useForm({ code: '' })
const disableForm = useForm({ password: '', code: '' })
const recoveryForm = useForm({ password: '', code: '', regenerate: false })
const recoveryCodes = computed(() => flash.value.recoveryCodes ?? [])
const twoFactorRequired = computed(() =>
config.value.features.two_factor && config.value.features.two_factor_required,
)
function enable() {
enableForm.post('/profile/two-factor', {
function updateProfile() {
form.patch('/profile', {
preserveScroll: true,
onFinish: () => enableForm.reset(),
})
}
function confirm() {
confirmForm.post('/profile/two-factor/confirm', {
preserveScroll: true,
onFinish: () => confirmForm.reset(),
})
}
function disable() {
disableForm.delete('/profile/two-factor', {
preserveScroll: true,
onFinish: () => disableForm.reset(),
})
}
function recoveryCodesAction(regenerate: boolean) {
recoveryForm.regenerate = regenerate
recoveryForm.post('/profile/two-factor/recovery-codes', {
preserveScroll: true,
onFinish: () => recoveryForm.reset(),
onSuccess: () => form.defaults(),
})
}
</script>
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar title="Profile" />
</template>
<ProfileSettingsPanel>
<UAlert
v-if="flash.success"
color="success"
icon="i-lucide-check-circle"
:title="flash.success"
/>
<template #body>
<div class="mx-auto w-full max-w-3xl space-y-6">
<UAlert
v-if="flash.success"
color="success"
icon="i-lucide-check-circle"
:title="flash.success"
/>
<UAlert
v-if="twoFactorRequired && !twoFactor.enabled"
color="warning"
icon="i-lucide-shield-alert"
title="Two-factor authentication is required"
description="Finish setup before accessing the rest of the application."
<form @submit.prevent="updateProfile">
<UPageCard
title="Profile"
description="Manage the personal information used for your account."
variant="naked"
orientation="horizontal"
class="mb-4"
>
<UButton
type="submit"
label="Save changes"
color="neutral"
class="w-fit lg:ms-auto"
:loading="form.processing"
:disabled="!form.isDirty"
/>
</UPageCard>
<UCard>
<div class="flex flex-col gap-5 sm:flex-row sm:items-center">
<UAvatar :alt="user?.full_name" size="3xl" />
<div class="min-w-0 flex-1">
<h1 class="truncate text-xl font-semibold">
{{ user?.full_name }}
</h1>
<p class="text-muted truncate text-sm">
@{{ user?.username }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<UBadge color="neutral" variant="subtle" icon="i-lucide-mail">
{{ user?.email }}
</UBadge>
<UBadge
:color="user?.email_verified_at ? 'success' : 'warning'"
variant="subtle"
:icon="user?.email_verified_at ? 'i-lucide-badge-check' : 'i-lucide-circle-alert'"
>
{{ user?.email_verified_at ? 'Email verified' : 'Email not verified' }}
</UBadge>
</div>
</div>
</div>
</UCard>
<UPageCard variant="subtle">
<UFormField
label="Avatar"
description="Generated from your account name until avatar uploads are configured."
class="flex items-center justify-between gap-4"
>
<UAvatar :alt="user?.full_name" size="lg" />
</UFormField>
<UCard>
<template #header>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold">
Two-factor authentication
</h2>
<p class="text-muted mt-1 text-sm">
Protect your account with a time-based code from an authenticator app.
</p>
</div>
<UBadge :color="twoFactor.enabled ? 'success' : twoFactor.pending ? 'warning' : 'neutral'">
{{ twoFactor.enabled ? 'Enabled' : twoFactor.pending ? 'Setup pending' : 'Disabled' }}
</UBadge>
</div>
</template>
<USeparator />
<UAlert
v-if="!twoFactor.available"
color="neutral"
icon="i-lucide-shield-off"
title="Two-factor authentication is not available"
description="An administrator can enable this feature through the authentication configuration."
<UFormField
label="First name"
name="first_name"
description="Used to personalize your account."
required
:error="form.errors.first_name"
class="flex max-sm:flex-col items-start justify-between gap-4"
>
<UInput
v-model="form.first_name"
autocomplete="given-name"
class="w-full sm:w-64"
/>
</UFormField>
<form v-else-if="!twoFactor.enabled && !twoFactor.pending" class="space-y-4" @submit.prevent="enable">
<UAlert
v-if="!twoFactor.requiresPassword"
color="info"
icon="i-lucide-info"
title="Your social login session will authorize enrollment."
<USeparator />
<UFormField
label="Last name"
name="last_name"
description="Displayed together with your first name."
required
:error="form.errors.last_name"
class="flex max-sm:flex-col items-start justify-between gap-4"
>
<UInput
v-model="form.last_name"
autocomplete="family-name"
class="w-full sm:w-64"
/>
</UFormField>
<USeparator />
<UFormField
label="Username"
name="username"
description="Your unique username for signing in."
required
:error="form.errors.username"
class="flex max-sm:flex-col items-start justify-between gap-4"
>
<UInput
v-model="form.username"
autocomplete="username"
class="w-full sm:w-64"
/>
</UFormField>
<USeparator />
<UFormField
label="Email"
name="email"
description="Changing it requires email verification again when enabled."
required
:error="form.errors.email"
class="flex max-sm:flex-col items-start justify-between gap-4"
>
<div class="flex w-full flex-col items-end gap-2 sm:w-64">
<UInput
v-model="form.email"
type="email"
autocomplete="email"
class="w-full"
/>
<UFormField
v-if="twoFactor.requiresPassword"
label="Current password"
name="password"
:error="enableForm.errors.password"
<UBadge
:color="user?.email_verified_at ? 'success' : 'warning'"
variant="subtle"
:icon="user?.email_verified_at ? 'i-lucide-badge-check' : 'i-lucide-circle-alert'"
>
<UInput
v-model="enableForm.password"
type="password"
autocomplete="current-password"
class="w-full"
/>
</UFormField>
<UButton type="submit" :loading="enableForm.processing">
Enable two-factor authentication
</UButton>
</form>
<div v-else-if="twoFactor.pending" class="space-y-6">
<div class="grid gap-6 md:grid-cols-[auto_1fr]">
<img
v-if="twoFactor.qrCodeDataUri"
:src="twoFactor.qrCodeDataUri"
alt="Two-factor authenticator QR code"
class="size-48 rounded-lg border bg-white p-2"
>
<div class="space-y-3">
<h3 class="font-medium">
Scan this QR code
</h3>
<p class="text-muted text-sm">
Scan it with any TOTP-compatible authenticator app, then enter the generated code below.
</p>
<div>
<p class="text-muted mb-1 text-xs">
Manual setup key
</p>
<code class="block break-all rounded bg-elevated p-2 text-sm">{{ twoFactor.secretKey }}</code>
</div>
</div>
</div>
<form class="space-y-4" @submit.prevent="confirm">
<UFormField
label="Six-digit authentication code"
name="code"
:error="confirmForm.errors.code"
>
<UInput
v-model="confirmForm.code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="123456"
class="w-full"
/>
</UFormField>
<div class="flex flex-wrap gap-3">
<UButton type="submit" :loading="confirmForm.processing">
Confirm setup
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
:loading="disableForm.processing"
@click="disable"
>
Cancel setup
</UButton>
</div>
</form>
{{ user?.email_verified_at ? 'Email verified' : 'Email not verified' }}
</UBadge>
</div>
<div v-else class="space-y-6">
<UAlert
color="success"
variant="subtle"
icon="i-lucide-shield-check"
title="Your account requires a second factor when signing in."
/>
<div class="space-y-4 border-t pt-6">
<div>
<h3 class="font-medium">
Recovery codes
</h3>
<p class="text-muted text-sm">
Re-authenticate to reveal your codes or replace them with a new set.
</p>
</div>
<UFormField
v-if="twoFactor.requiresPassword"
label="Current password"
name="password"
:error="recoveryForm.errors.password"
>
<UInput
v-model="recoveryForm.password"
type="password"
autocomplete="current-password"
class="w-full"
/>
</UFormField>
<UFormField
label="Authentication or recovery code"
name="code"
:error="recoveryForm.errors.code"
>
<UInput
v-model="recoveryForm.code"
autocomplete="one-time-code"
class="w-full"
/>
</UFormField>
<div class="flex flex-wrap gap-3">
<UButton
variant="outline"
:loading="recoveryForm.processing && !recoveryForm.regenerate"
@click="recoveryCodesAction(false)"
>
Reveal recovery codes
</UButton>
<UButton
color="warning"
variant="outline"
:loading="recoveryForm.processing && recoveryForm.regenerate"
@click="recoveryCodesAction(true)"
>
Generate new codes
</UButton>
</div>
</div>
<UAlert
v-if="twoFactorRequired"
color="neutral"
variant="subtle"
icon="i-lucide-lock-keyhole"
title="Two-factor authentication is required by your administrator."
description="It cannot be disabled while the mandatory security policy is active."
/>
<form v-else class="space-y-4 border-t pt-6" @submit.prevent="disable">
<div>
<h3 class="font-medium text-error">
Disable two-factor authentication
</h3>
<p class="text-muted text-sm">
This removes the additional protection from your account.
</p>
</div>
<UFormField
v-if="twoFactor.requiresPassword"
label="Current password"
name="password"
:error="disableForm.errors.password"
>
<UInput
v-model="disableForm.password"
type="password"
autocomplete="current-password"
class="w-full"
/>
</UFormField>
<UFormField
label="Authentication or recovery code"
name="code"
:error="disableForm.errors.code"
>
<UInput
v-model="disableForm.code"
autocomplete="one-time-code"
class="w-full"
/>
</UFormField>
<UButton type="submit" color="error" :loading="disableForm.processing">
Disable two-factor authentication
</UButton>
</form>
</div>
</UCard>
<UCard v-if="recoveryCodes.length">
<template #header>
<div>
<h2 class="font-semibold">
Save your recovery codes
</h2>
<p class="text-muted mt-1 text-sm">
Store these codes somewhere safe. Each code can be used once.
</p>
</div>
</template>
<RecoveryCodesPanel :codes="recoveryCodes" />
</UCard>
</div>
</template>
</UDashboardPanel>
</UFormField>
</UPageCard>
</form>
</ProfileSettingsPanel>
</template>

View File

@@ -16,13 +16,17 @@ const { config } = useAuth()
</script>
<template>
<component :is="linkToHome ? Link : 'div'" :href="linkToHome ? '/' : undefined" class="flex items-center gap-3">
<component
:is="linkToHome ? Link : 'div'"
:href="linkToHome ? '/' : undefined"
class="flex min-w-0 items-center gap-3 overflow-hidden"
>
<!--
Replace this placeholder with your actual logo:
<img src="/images/logo.svg" alt="Logo" :class="iconSizeClass">
-->
<div
class="flex items-center justify-center rounded-xl bg-primary-500 text-white font-bold"
class="flex shrink-0 items-center justify-center rounded-xl bg-primary-500 text-white font-bold"
:class="{
'size-8 text-sm': size === 'sm',
'size-12 text-xl': size === 'md',
@@ -34,7 +38,7 @@ const { config } = useAuth()
<span
v-if="showText"
class="font-semibold text-gray-900 dark:text-white"
class="min-w-0 truncate font-semibold text-gray-900 dark:text-white"
:class="{
'text-lg': size === 'sm',
'text-2xl': size === 'md',

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui'
const links = [[
{
label: 'General',
icon: 'i-lucide-user',
to: '/profile',
exact: true,
},
{
label: 'Security',
icon: 'i-lucide-shield',
to: '/profile/security',
},
]] satisfies NavigationMenuItem[][]
</script>
<template>
<UDashboardPanel id="profile" :ui="{ body: 'lg:py-12' }">
<template #header>
<UDashboardNavbar title="Profile">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<UNavigationMenu
:items="links"
highlight
class="-mx-1 flex-1"
/>
</UDashboardToolbar>
</template>
<template #body>
<div class="mx-auto flex w-full max-w-2xl flex-col gap-4 sm:gap-6 lg:gap-12">
<slot />
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -41,41 +41,57 @@ const userMenuItems: DropdownMenuItem[][] = [
<template>
<UApp>
<UDashboardGroup>
<UDashboardSidebar collapsible>
<template #header>
<Logo :link-to-home="false" size="sm" />
<UDashboardGroup unit="rem" storage="local">
<UDashboardSidebar
id="main"
resizable
collapsible
class="bg-elevated/25"
:min-size="14"
:default-size="16"
:max-size="24"
:collapsed-size="4"
:ui="{ footer: 'lg:border-t lg:border-default' }"
>
<template #header="{ collapsed }">
<Logo
:link-to-home="false"
size="sm"
:show-text="!collapsed"
/>
</template>
<UNavigationMenu :items="sidebarLinks" orientation="vertical" />
<template #default="{ collapsed }">
<UNavigationMenu
:items="sidebarLinks"
orientation="vertical"
:collapsed="collapsed"
tooltip
/>
</template>
<template #footer>
<template #footer="{ collapsed }">
<UDropdownMenu
:items="userMenuItems"
:content="{ align: 'start', side: 'top', sideOffset: 8 }"
:ui="{ content: 'w-64' }"
:content="{ align: 'center', side: 'top', sideOffset: 8, collisionPadding: 12 }"
:ui="{ content: collapsed ? 'w-48' : 'w-(--reka-dropdown-menu-trigger-width)' }"
>
<UButton
:avatar="{ alt: user?.full_name }"
:label="collapsed ? undefined : user?.full_name"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
color="neutral"
variant="ghost"
class="w-full justify-start px-2 py-2"
block
:square="collapsed"
class="data-[state=open]:bg-elevated"
aria-label="Open user menu"
:loading="logoutForm.processing"
>
<UAvatar :alt="user?.full_name" size="sm" />
<span class="min-w-0 flex-1 text-left">
<span class="block truncate text-sm font-medium text-highlighted">
{{ user?.full_name }}
</span>
<span class="block truncate text-xs font-normal text-muted">
{{ user?.email }}
</span>
</span>
<UIcon
name="i-lucide-chevrons-up-down"
class="size-4 shrink-0 text-muted"
/>
</UButton>
:ui="{
label: 'truncate text-left',
trailingIcon: 'text-dimmed',
}"
/>
</UDropdownMenu>
</template>
</UDashboardSidebar>

View File

@@ -11,6 +11,7 @@ use App\Http\Controllers\Auth\TwoFactorChallengeController;
use App\Http\Controllers\Auth\TwoFactorSettingsController;
use App\Http\Controllers\Auth\TwoFactorSetupController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ProfileSecurityController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
@@ -54,8 +55,16 @@ Route::middleware('auth')->group(function () {
->middleware('throttle:auth.account')
->name('verification.send');
Route::get('profile', ProfileController::class)->name('profile.show');
Route::redirect('security', '/profile')->name('profile.security-redirect');
Route::get('profile', [ProfileController::class, 'show'])->name('profile.show');
Route::patch('profile', [ProfileController::class, 'update'])
->middleware('throttle:auth.account')
->name('profile.update');
Route::get('profile/security', [ProfileSecurityController::class, 'show'])
->name('profile.security');
Route::put('profile/password', [ProfileSecurityController::class, 'updatePassword'])
->middleware('throttle:auth.account')
->name('profile.password.update');
Route::redirect('security', '/profile/security')->name('profile.security-redirect');
Route::get('two-factor-setup', TwoFactorSetupController::class)
->name('two-factor.setup');
Route::get('two-factor-setup/complete', [TwoFactorSetupController::class, 'complete'])

View File

@@ -48,22 +48,22 @@ function enableTwoFactorForTest(User $user): array
];
}
it('keeps the profile available while two-factor settings are disabled', function () {
it('keeps profile security available while two-factor settings are disabled', function () {
config(['auth-ui.features.two_factor' => false]);
$this->actingAs(User::factory()->create())
->get('/profile')
->get('/profile/security')
->assertInertia(fn (Assert $page) => $page
->component('Profile/Show')
->component('Profile/Security')
->where('twoFactor.available', false)
->where('twoFactor.enabled', false)
->where('twoFactor.pending', false));
});
it('redirects the former security page to the profile', function () {
it('redirects the former security page to profile security', function () {
$this->actingAs(User::factory()->create())
->get('/security')
->assertRedirect('/profile');
->assertRedirect('/profile/security');
});
it('bypasses stored two-factor configuration when the feature is disabled', function () {
@@ -248,15 +248,15 @@ it('requires the current password before a password user can enroll', function (
$this->actingAs($user)
->post('/profile/two-factor', ['password' => 'password123'])
->assertRedirect('/profile');
->assertRedirect('/profile/security');
expect($user->fresh()->two_factor_secret)->not->toBeNull()
->and($user->fresh()->two_factor_confirmed_at)->toBeNull();
$this->actingAs($user)
->get('/profile')
->get('/profile/security')
->assertInertia(fn (Assert $page) => $page
->component('Profile/Show')
->component('Profile/Security')
->where('twoFactor.pending', true)
->where('twoFactor.enabled', false)
->where('twoFactor.requiresPassword', true)
@@ -274,7 +274,7 @@ it('confirms enrollment and returns recovery codes once', function () {
$this->actingAs($user)
->post('/profile/two-factor/confirm', ['code' => $code])
->assertRedirect('/profile')
->assertRedirect('/profile/security')
->assertSessionHas('recoveryCodes', fn (array $codes) => count($codes) === 8);
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue();
@@ -366,7 +366,7 @@ it('requires a valid second factor for social-only users to disable protection',
$this->actingAs($user)
->delete('/profile/two-factor', ['code' => $twoFactor['code']])
->assertRedirect('/profile');
->assertRedirect('/profile/security');
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
});
@@ -387,7 +387,7 @@ it('requires both password and second factor for password users to disable prote
'password' => 'password123',
'code' => $twoFactor['code'],
])
->assertRedirect('/profile');
->assertRedirect('/profile/security');
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
Notification::assertSentTo($user, TwoFactorSecurityNotification::class);
@@ -410,7 +410,7 @@ it('requires both password and second factor to reveal recovery codes', function
'code' => $twoFactor['code'],
'regenerate' => false,
])
->assertRedirect('/profile')
->assertRedirect('/profile/security')
->assertSessionHas('recoveryCodes');
});
@@ -469,7 +469,7 @@ it('consumes recovery codes used to authorize a sensitive settings action', func
'code' => $recoveryCode,
'regenerate' => false,
])
->assertRedirect('/profile')
->assertRedirect('/profile/security')
->assertSessionHas('recoveryCodes');
expect($user->fresh()->recoveryCodes())->not->toContain($recoveryCode);

View File

@@ -0,0 +1,132 @@
<?php
use App\Models\User;
use App\Notifications\PasswordChangedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'auth-ui.features.email_verification' => false,
'auth-ui.features.two_factor_required' => false,
]);
});
it('renders separate general and security profile pages', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/profile')
->assertInertia(fn (Assert $page) => $page
->component('Profile/Show'));
$this->actingAs($user)
->get('/profile/security')
->assertInertia(fn (Assert $page) => $page
->component('Profile/Security')
->where('twoFactor.requiresPassword', true));
});
it('updates profile details and requires email reverification after an email change', function () {
$user = User::factory()->create([
'email' => 'before@example.com',
'email_verified_at' => now(),
]);
$this->actingAs($user)
->patch('/profile', [
'first_name' => 'Updated',
'last_name' => 'Person',
'username' => 'updated-person',
'email' => 'after@example.com',
])
->assertRedirect('/profile')
->assertSessionHas('success');
$user->refresh();
expect($user->first_name)->toBe('Updated')
->and($user->last_name)->toBe('Person')
->and($user->username)->toBe('updated-person')
->and($user->email)->toBe('after@example.com')
->and($user->email_verified_at)->toBeNull();
});
it('rejects a case-insensitive duplicate username on profile update', function () {
User::factory()->create(['username' => 'ExistingUser']);
$user = User::factory()->create(['username' => 'different-user']);
$this->actingAs($user)
->patch('/profile', [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'username' => 'existinguser',
'email' => $user->email,
])
->assertSessionHasErrors('username');
expect($user->fresh()->username)->toBe('different-user');
});
it('requires the current password and revokes other sessions after a password change', function () {
Notification::fake();
config(['session.driver' => 'database']);
$user = User::factory()->create(['password' => 'current secure password']);
DB::table('sessions')->insert([
'id' => 'another-authenticated-session',
'user_id' => $user->id,
'ip_address' => '192.0.2.10',
'user_agent' => 'Test',
'payload' => 'payload',
'last_activity' => now()->timestamp,
]);
$this->actingAs($user)
->put('/profile/password', [
'current_password' => 'incorrect password',
'password' => 'a completely new secure password',
'password_confirmation' => 'a completely new secure password',
])
->assertSessionHasErrors('current_password');
$this->actingAs($user)
->put('/profile/password', [
'current_password' => 'current secure password',
'password' => 'a completely new secure password',
'password_confirmation' => 'a completely new secure password',
])
->assertRedirect('/profile/security')
->assertSessionHas('auth.session_version', 1);
$user->refresh();
expect(Hash::check('a completely new secure password', $user->password))->toBeTrue()
->and($user->auth_session_version)->toBe(1)
->and(DB::table('sessions')->where('id', 'another-authenticated-session')->exists())->toBeFalse();
$this->assertAuthenticatedAs($user);
Notification::assertSentTo($user, PasswordChangedNotification::class);
});
it('allows a social-only user to establish a password', function () {
Notification::fake();
$user = User::factory()->social()->create();
$this->actingAs($user)
->put('/profile/password', [
'current_password' => '',
'password' => 'a secure password for social login',
'password_confirmation' => 'a secure password for social login',
])
->assertRedirect('/profile/security');
expect(Hash::check(
'a secure password for social login',
$user->fresh()->password
))->toBeTrue();
});