feat(profile): align settings with dashboard layout
This commit is contained in:
@@ -65,7 +65,7 @@ class TwoFactorSettingsController extends Controller
|
|||||||
|
|
||||||
$redirect = $request->routeIs('two-factor.setup.confirm')
|
$redirect = $request->routeIs('two-factor.setup.confirm')
|
||||||
? redirect()->route('two-factor.setup')
|
? redirect()->route('two-factor.setup')
|
||||||
: redirect()->route('profile.show');
|
: redirect()->route('profile.security');
|
||||||
|
|
||||||
return $redirect
|
return $redirect
|
||||||
->with('success', 'Two-factor authentication is now enabled.')
|
->with('success', 'Two-factor authentication is now enabled.')
|
||||||
@@ -102,7 +102,7 @@ class TwoFactorSettingsController extends Controller
|
|||||||
));
|
));
|
||||||
$securityEvents->record('two_factor.disabled', $user, $request);
|
$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.');
|
->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);
|
$securityEvents->record('two_factor.recovery_codes_revealed', $user, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('profile.show')
|
return redirect()->route('profile.security')
|
||||||
->with('success', $request->boolean('regenerate')
|
->with('success', $request->boolean('regenerate')
|
||||||
? 'New recovery codes have been generated. Previous codes no longer work.'
|
? 'New recovery codes have been generated. Previous codes no longer work.'
|
||||||
: 'Recovery codes revealed.')
|
: 'Recovery codes revealed.')
|
||||||
@@ -158,7 +158,7 @@ class TwoFactorSettingsController extends Controller
|
|||||||
{
|
{
|
||||||
return $request->routeIs('two-factor.setup.*')
|
return $request->routeIs('two-factor.setup.*')
|
||||||
? redirect()->route('two-factor.setup')
|
? redirect()->route('two-factor.setup')
|
||||||
: redirect()->route('profile.show');
|
: redirect()->route('profile.security');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePasswordForPasswordUser(Request $request, User $user): void
|
private function validatePasswordForPasswordUser(Request $request, User $user): void
|
||||||
|
|||||||
@@ -2,26 +2,50 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\Profile\UpdateProfileRequest;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\TwoFactorEnrollmentState;
|
use App\Services\Auth\SecurityEventRecorder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display the authenticated user's profile and security settings.
|
* Display the authenticated user's profile settings.
|
||||||
*/
|
*/
|
||||||
public function __invoke(
|
public function show(): Response
|
||||||
Request $request,
|
{
|
||||||
TwoFactorEnrollmentState $enrollmentState
|
return Inertia::render('Profile/Show');
|
||||||
): Response {
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the authenticated user's profile settings.
|
||||||
|
*/
|
||||||
|
public function update(
|
||||||
|
UpdateProfileRequest $request,
|
||||||
|
SecurityEventRecorder $securityEvents
|
||||||
|
): RedirectResponse {
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
$emailChanged = $user->email !== $request->validated('email');
|
||||||
|
|
||||||
return Inertia::render('Profile/Show', [
|
$user->fill($request->validated());
|
||||||
'twoFactor' => $enrollmentState->for($user),
|
|
||||||
|
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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
app/Http/Controllers/ProfileSecurityController.php
Normal file
71
app/Http/Controllers/ProfileSecurityController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ class AddSecurityHeaders
|
|||||||
'login',
|
'login',
|
||||||
'password.*',
|
'password.*',
|
||||||
'two-factor.*',
|
'two-factor.*',
|
||||||
'profile.show',
|
'profile.*',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
app/Http/Requests/Profile/UpdatePasswordRequest.php
Normal file
31
app/Http/Requests/Profile/UpdatePasswordRequest.php
Normal 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()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Requests/Profile/UpdateProfileRequest.php
Normal file
47
app/Http/Requests/Profile/UpdateProfileRequest.php
Normal 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()),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,6 @@ class TwoFactorFailedNotification extends Notification implements ShouldQueue
|
|||||||
->line("IP address: {$this->ipAddress}")
|
->line("IP address: {$this->ipAddress}")
|
||||||
->line("Browser: {$this->userAgent}")
|
->line("Browser: {$this->userAgent}")
|
||||||
->line('If this was not you, change your password immediately.')
|
->line('If this was not you, change your password immediately.')
|
||||||
->action('Review account security', url('/profile'));
|
->action('Review account security', url('/profile/security'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,6 @@ class TwoFactorSecurityNotification extends Notification implements ShouldQueue
|
|||||||
->line("Time: {$this->occurredAt}")
|
->line("Time: {$this->occurredAt}")
|
||||||
->line("IP address: {$this->ipAddress}")
|
->line("IP address: {$this->ipAddress}")
|
||||||
->line('If you did not perform this action, change your password and contact support immediately.')
|
->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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import antfu from '@antfu/eslint-config'
|
import antfu from '@antfu/eslint-config'
|
||||||
|
|
||||||
export default antfu({
|
export default antfu({
|
||||||
|
ignores: [
|
||||||
|
'storage/inertia-devtools/**',
|
||||||
|
],
|
||||||
vue: true,
|
vue: true,
|
||||||
typescript: true,
|
typescript: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ const { user } = useAuth()
|
|||||||
<template>
|
<template>
|
||||||
<UDashboardPanel>
|
<UDashboardPanel>
|
||||||
<template #header>
|
<template #header>
|
||||||
<UDashboardNavbar title="Dashboard" />
|
<UDashboardNavbar title="Dashboard">
|
||||||
|
<template #leading>
|
||||||
|
<UDashboardSidebarCollapse />
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
|
|||||||
388
resources/js/Pages/Profile/Security.vue
Normal file
388
resources/js/Pages/Profile/Security.vue
Normal 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>
|
||||||
@@ -1,105 +1,134 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3'
|
import { useForm } from '@inertiajs/vue3'
|
||||||
import { computed } from 'vue'
|
import ProfileSettingsPanel from '@/components/profile/ProfileSettingsPanel.vue'
|
||||||
import RecoveryCodesPanel from '@/components/auth/RecoveryCodesPanel.vue'
|
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||||
|
|
||||||
interface TwoFactorState {
|
|
||||||
available: boolean
|
|
||||||
enabled: boolean
|
|
||||||
pending: boolean
|
|
||||||
requiresPassword: boolean
|
|
||||||
qrCodeDataUri: string | null
|
|
||||||
secretKey: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
layout: DashboardLayout,
|
layout: DashboardLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
defineProps<{
|
const { flash, user } = useAuth()
|
||||||
twoFactor: TwoFactorState
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { config, flash, user } = useAuth()
|
const form = useForm({
|
||||||
|
first_name: user.value?.first_name ?? '',
|
||||||
const enableForm = useForm({ password: '' })
|
last_name: user.value?.last_name ?? '',
|
||||||
const confirmForm = useForm({ code: '' })
|
username: user.value?.username ?? '',
|
||||||
const disableForm = useForm({ password: '', code: '' })
|
email: user.value?.email ?? '',
|
||||||
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', {
|
|
||||||
preserveScroll: true,
|
|
||||||
onFinish: () => enableForm.reset(),
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
function confirm() {
|
function updateProfile() {
|
||||||
confirmForm.post('/profile/two-factor/confirm', {
|
form.patch('/profile', {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onFinish: () => confirmForm.reset(),
|
onSuccess: () => form.defaults(),
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardPanel>
|
<ProfileSettingsPanel>
|
||||||
<template #header>
|
|
||||||
<UDashboardNavbar title="Profile" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<div class="mx-auto w-full max-w-3xl space-y-6">
|
|
||||||
<UAlert
|
<UAlert
|
||||||
v-if="flash.success"
|
v-if="flash.success"
|
||||||
color="success"
|
color="success"
|
||||||
icon="i-lucide-check-circle"
|
icon="i-lucide-check-circle"
|
||||||
:title="flash.success"
|
: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."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UCard>
|
<form @submit.prevent="updateProfile">
|
||||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-center">
|
<UPageCard
|
||||||
<UAvatar :alt="user?.full_name" size="3xl" />
|
title="Profile"
|
||||||
<div class="min-w-0 flex-1">
|
description="Manage the personal information used for your account."
|
||||||
<h1 class="truncate text-xl font-semibold">
|
variant="naked"
|
||||||
{{ user?.full_name }}
|
orientation="horizontal"
|
||||||
</h1>
|
class="mb-4"
|
||||||
<p class="text-muted truncate text-sm">
|
>
|
||||||
@{{ user?.username }}
|
<UButton
|
||||||
</p>
|
type="submit"
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
label="Save changes"
|
||||||
<UBadge color="neutral" variant="subtle" icon="i-lucide-mail">
|
color="neutral"
|
||||||
{{ user?.email }}
|
class="w-fit lg:ms-auto"
|
||||||
</UBadge>
|
:loading="form.processing"
|
||||||
|
:disabled="!form.isDirty"
|
||||||
|
/>
|
||||||
|
</UPageCard>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<USeparator />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
<UBadge
|
<UBadge
|
||||||
:color="user?.email_verified_at ? 'success' : 'warning'"
|
:color="user?.email_verified_at ? 'success' : 'warning'"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -108,239 +137,8 @@ function recoveryCodesAction(regenerate: boolean) {
|
|||||||
{{ user?.email_verified_at ? 'Email verified' : 'Email not verified' }}
|
{{ user?.email_verified_at ? 'Email verified' : 'Email not verified' }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
</UFormField>
|
||||||
<UButton type="submit" :loading="enableForm.processing">
|
</UPageCard>
|
||||||
Enable two-factor authentication
|
|
||||||
</UButton>
|
|
||||||
</form>
|
</form>
|
||||||
|
</ProfileSettingsPanel>
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ const { config } = useAuth()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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:
|
Replace this placeholder with your actual logo:
|
||||||
<img src="/images/logo.svg" alt="Logo" :class="iconSizeClass">
|
<img src="/images/logo.svg" alt="Logo" :class="iconSizeClass">
|
||||||
-->
|
-->
|
||||||
<div
|
<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="{
|
:class="{
|
||||||
'size-8 text-sm': size === 'sm',
|
'size-8 text-sm': size === 'sm',
|
||||||
'size-12 text-xl': size === 'md',
|
'size-12 text-xl': size === 'md',
|
||||||
@@ -34,7 +38,7 @@ const { config } = useAuth()
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
v-if="showText"
|
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="{
|
:class="{
|
||||||
'text-lg': size === 'sm',
|
'text-lg': size === 'sm',
|
||||||
'text-2xl': size === 'md',
|
'text-2xl': size === 'md',
|
||||||
|
|||||||
43
resources/js/components/profile/ProfileSettingsPanel.vue
Normal file
43
resources/js/components/profile/ProfileSettingsPanel.vue
Normal 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>
|
||||||
@@ -41,41 +41,57 @@ const userMenuItems: DropdownMenuItem[][] = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UApp>
|
<UApp>
|
||||||
<UDashboardGroup>
|
<UDashboardGroup unit="rem" storage="local">
|
||||||
<UDashboardSidebar collapsible>
|
<UDashboardSidebar
|
||||||
<template #header>
|
id="main"
|
||||||
<Logo :link-to-home="false" size="sm" />
|
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>
|
</template>
|
||||||
|
|
||||||
<UNavigationMenu :items="sidebarLinks" orientation="vertical" />
|
<template #default="{ collapsed }">
|
||||||
|
<UNavigationMenu
|
||||||
|
:items="sidebarLinks"
|
||||||
|
orientation="vertical"
|
||||||
|
:collapsed="collapsed"
|
||||||
|
tooltip
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer="{ collapsed }">
|
||||||
<UDropdownMenu
|
<UDropdownMenu
|
||||||
:items="userMenuItems"
|
:items="userMenuItems"
|
||||||
:content="{ align: 'start', side: 'top', sideOffset: 8 }"
|
:content="{ align: 'center', side: 'top', sideOffset: 8, collisionPadding: 12 }"
|
||||||
:ui="{ content: 'w-64' }"
|
:ui="{ content: collapsed ? 'w-48' : 'w-(--reka-dropdown-menu-trigger-width)' }"
|
||||||
>
|
>
|
||||||
<UButton
|
<UButton
|
||||||
|
:avatar="{ alt: user?.full_name }"
|
||||||
|
:label="collapsed ? undefined : user?.full_name"
|
||||||
|
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="ghost"
|
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"
|
aria-label="Open user menu"
|
||||||
:loading="logoutForm.processing"
|
:loading="logoutForm.processing"
|
||||||
>
|
:ui="{
|
||||||
<UAvatar :alt="user?.full_name" size="sm" />
|
label: 'truncate text-left',
|
||||||
<span class="min-w-0 flex-1 text-left">
|
trailingIcon: 'text-dimmed',
|
||||||
<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>
|
|
||||||
</UDropdownMenu>
|
</UDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardSidebar>
|
</UDashboardSidebar>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Http\Controllers\Auth\TwoFactorChallengeController;
|
|||||||
use App\Http\Controllers\Auth\TwoFactorSettingsController;
|
use App\Http\Controllers\Auth\TwoFactorSettingsController;
|
||||||
use App\Http\Controllers\Auth\TwoFactorSetupController;
|
use App\Http\Controllers\Auth\TwoFactorSetupController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Http\Controllers\ProfileSecurityController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware('guest')->group(function () {
|
Route::middleware('guest')->group(function () {
|
||||||
@@ -54,8 +55,16 @@ Route::middleware('auth')->group(function () {
|
|||||||
->middleware('throttle:auth.account')
|
->middleware('throttle:auth.account')
|
||||||
->name('verification.send');
|
->name('verification.send');
|
||||||
|
|
||||||
Route::get('profile', ProfileController::class)->name('profile.show');
|
Route::get('profile', [ProfileController::class, 'show'])->name('profile.show');
|
||||||
Route::redirect('security', '/profile')->name('profile.security-redirect');
|
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)
|
Route::get('two-factor-setup', TwoFactorSetupController::class)
|
||||||
->name('two-factor.setup');
|
->name('two-factor.setup');
|
||||||
Route::get('two-factor-setup/complete', [TwoFactorSetupController::class, 'complete'])
|
Route::get('two-factor-setup/complete', [TwoFactorSetupController::class, 'complete'])
|
||||||
|
|||||||
@@ -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]);
|
config(['auth-ui.features.two_factor' => false]);
|
||||||
|
|
||||||
$this->actingAs(User::factory()->create())
|
$this->actingAs(User::factory()->create())
|
||||||
->get('/profile')
|
->get('/profile/security')
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('Profile/Show')
|
->component('Profile/Security')
|
||||||
->where('twoFactor.available', false)
|
->where('twoFactor.available', false)
|
||||||
->where('twoFactor.enabled', false)
|
->where('twoFactor.enabled', false)
|
||||||
->where('twoFactor.pending', 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())
|
$this->actingAs(User::factory()->create())
|
||||||
->get('/security')
|
->get('/security')
|
||||||
->assertRedirect('/profile');
|
->assertRedirect('/profile/security');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bypasses stored two-factor configuration when the feature is disabled', function () {
|
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)
|
$this->actingAs($user)
|
||||||
->post('/profile/two-factor', ['password' => 'password123'])
|
->post('/profile/two-factor', ['password' => 'password123'])
|
||||||
->assertRedirect('/profile');
|
->assertRedirect('/profile/security');
|
||||||
|
|
||||||
expect($user->fresh()->two_factor_secret)->not->toBeNull()
|
expect($user->fresh()->two_factor_secret)->not->toBeNull()
|
||||||
->and($user->fresh()->two_factor_confirmed_at)->toBeNull();
|
->and($user->fresh()->two_factor_confirmed_at)->toBeNull();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get('/profile')
|
->get('/profile/security')
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('Profile/Show')
|
->component('Profile/Security')
|
||||||
->where('twoFactor.pending', true)
|
->where('twoFactor.pending', true)
|
||||||
->where('twoFactor.enabled', false)
|
->where('twoFactor.enabled', false)
|
||||||
->where('twoFactor.requiresPassword', true)
|
->where('twoFactor.requiresPassword', true)
|
||||||
@@ -274,7 +274,7 @@ it('confirms enrollment and returns recovery codes once', function () {
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->post('/profile/two-factor/confirm', ['code' => $code])
|
->post('/profile/two-factor/confirm', ['code' => $code])
|
||||||
->assertRedirect('/profile')
|
->assertRedirect('/profile/security')
|
||||||
->assertSessionHas('recoveryCodes', fn (array $codes) => count($codes) === 8);
|
->assertSessionHas('recoveryCodes', fn (array $codes) => count($codes) === 8);
|
||||||
|
|
||||||
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue();
|
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)
|
$this->actingAs($user)
|
||||||
->delete('/profile/two-factor', ['code' => $twoFactor['code']])
|
->delete('/profile/two-factor', ['code' => $twoFactor['code']])
|
||||||
->assertRedirect('/profile');
|
->assertRedirect('/profile/security');
|
||||||
|
|
||||||
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
||||||
});
|
});
|
||||||
@@ -387,7 +387,7 @@ it('requires both password and second factor for password users to disable prote
|
|||||||
'password' => 'password123',
|
'password' => 'password123',
|
||||||
'code' => $twoFactor['code'],
|
'code' => $twoFactor['code'],
|
||||||
])
|
])
|
||||||
->assertRedirect('/profile');
|
->assertRedirect('/profile/security');
|
||||||
|
|
||||||
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
||||||
Notification::assertSentTo($user, TwoFactorSecurityNotification::class);
|
Notification::assertSentTo($user, TwoFactorSecurityNotification::class);
|
||||||
@@ -410,7 +410,7 @@ it('requires both password and second factor to reveal recovery codes', function
|
|||||||
'code' => $twoFactor['code'],
|
'code' => $twoFactor['code'],
|
||||||
'regenerate' => false,
|
'regenerate' => false,
|
||||||
])
|
])
|
||||||
->assertRedirect('/profile')
|
->assertRedirect('/profile/security')
|
||||||
->assertSessionHas('recoveryCodes');
|
->assertSessionHas('recoveryCodes');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -469,7 +469,7 @@ it('consumes recovery codes used to authorize a sensitive settings action', func
|
|||||||
'code' => $recoveryCode,
|
'code' => $recoveryCode,
|
||||||
'regenerate' => false,
|
'regenerate' => false,
|
||||||
])
|
])
|
||||||
->assertRedirect('/profile')
|
->assertRedirect('/profile/security')
|
||||||
->assertSessionHas('recoveryCodes');
|
->assertSessionHas('recoveryCodes');
|
||||||
|
|
||||||
expect($user->fresh()->recoveryCodes())->not->toContain($recoveryCode);
|
expect($user->fresh()->recoveryCodes())->not->toContain($recoveryCode);
|
||||||
|
|||||||
132
tests/Feature/ProfileSettingsTest.php
Normal file
132
tests/Feature/ProfileSettingsTest.php
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user