feat: implement consolidated authentication settings including session management and social provider integration

This commit is contained in:
Paulo Castellano 2026-05-03 17:42:44 -03:00
parent 0f385e1b59
commit 34272a573f
26 changed files with 1015 additions and 341 deletions

View file

@ -11,7 +11,7 @@
class CreateUser
{
/**
* @param array{name: string, email: string, password?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool} $data
* @param array{name: string, email: string, password?: string, google_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool} $data
*/
public static function execute(array $data): User
{
@ -27,6 +27,7 @@ public static function execute(array $data): User
'name' => data_get($data, 'name'),
'email' => data_get($data, 'email'),
'password' => data_get($data, 'password'),
'google_id' => data_get($data, 'google_id'),
'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null),
'account_id' => $account->id,
]);

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App\Settings;
use App\Http\Controllers\App\Controller;
use App\Http\Requests\App\Settings\AuthenticationPasswordRequest;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticationController extends Controller
{
private const array PROVIDERS = ['google'];
public function edit(Request $request): Response
{
$user = $request->user();
return Inertia::render('settings/profile/Authentication', [
'sessions' => $this->getSessions($request),
'hasPassword' => (bool) $user->password,
'connectedAccounts' => $this->getConnectedAccounts($user),
]);
}
public function updatePassword(AuthenticationPasswordRequest $request): RedirectResponse
{
$request->user()->update([
'password' => $request->password,
]);
return back()->with('flash.success', __('settings.flash.password_updated'));
}
public function destroyOtherSessions(Request $request): RedirectResponse
{
$user = $request->user();
if ($user->password) {
$request->validate([
'password' => ['required', 'string', 'current_password'],
]);
} else {
$request->validate([
'email_confirmation' => ['required', 'string', Rule::in([$user->email])],
], [
'email_confirmation.in' => __('settings.authentication.sessions.email_mismatch'),
]);
}
DB::table(config('session.table', 'sessions'))
->where('user_id', $user->id)
->where('id', '!=', $request->session()->getId())
->delete();
return back()->with('flash.success', __('settings.authentication.sessions.flash_logged_out'));
}
public function disconnectProvider(Request $request, string $provider): RedirectResponse
{
abort_unless(in_array($provider, self::PROVIDERS, true), 404);
$user = $request->user();
$column = "{$provider}_id";
if (! $user->{$column}) {
return back();
}
if (! $this->canDisconnect($user, $provider)) {
return back()->with('flash.error', __('settings.authentication.providers.flash_cannot_disconnect'));
}
$user->update([$column => null]);
return back()->with('flash.success', __('settings.authentication.providers.flash_disconnected', [
'provider' => ucfirst($provider),
]));
}
/**
* @return array<int, array{id: string, ip_address: string|null, user_agent: string|null, last_active: string, is_current: bool}>
*/
private function getSessions(Request $request): array
{
if (config('session.driver') !== 'database') {
return [];
}
return collect(
DB::table(config('session.table', 'sessions'))
->where('user_id', $request->user()->id)
->orderByDesc('last_activity')
->get()
)->map(fn ($session) => [
'id' => $session->id,
'ip_address' => $session->ip_address,
'user_agent' => $session->user_agent,
'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(),
'is_current' => $session->id === $request->session()->getId(),
])->values()->all();
}
/**
* @return array<int, array{provider: string, label: string, connected: bool, can_disconnect: bool}>
*/
private function getConnectedAccounts(User $user): array
{
$labels = [
'google' => 'Google',
];
return collect(self::PROVIDERS)->map(fn (string $provider) => [
'provider' => $provider,
'label' => $labels[$provider],
'connected' => (bool) $user->{"{$provider}_id"},
'can_disconnect' => $user->{"{$provider}_id"} && $this->canDisconnect($user, $provider),
])->values()->all();
}
private function canDisconnect(User $user, string $provider): bool
{
$remainingMethods = 0;
if ($user->password) {
$remainingMethods++;
}
foreach (self::PROVIDERS as $other) {
if ($other === $provider) {
continue;
}
if ($user->{"{$other}_id"}) {
$remainingMethods++;
}
}
return $remainingMethods > 0;
}
}

View file

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordController extends Controller
{
public function edit(): Response
{
return Inertia::render('settings/profile/Password');
}
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make(data_get($validated, 'password')),
]);
session()->flash('flash.banner', __('settings.flash.password_updated'));
session()->flash('flash.bannerStyle', 'success');
return back();
}
}

View file

@ -27,17 +27,23 @@ public function callback(): RedirectResponse
return redirect()->route('login');
}
$user = User::where('email', $googleUser->getEmail())->first();
$user = User::where('google_id', $googleUser->getId())
->orWhere('email', $googleUser->getEmail())
->first();
if ($user) {
return $this->loginExistingUser($user);
return $this->loginExistingUser($user, $googleUser->getId());
}
return $this->registerNewUser($googleUser);
}
private function loginExistingUser(User $user): RedirectResponse
private function loginExistingUser(User $user, string $googleId): RedirectResponse
{
if (! $user->google_id) {
$user->update(['google_id' => $googleId]);
}
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
@ -52,6 +58,7 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser):
$user = CreateUser::execute([
'name' => $googleUser->getName(),
'email' => $googleUser->getEmail(),
'google_id' => $googleUser->getId(),
'email_verified_at' => now(),
]);

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class AuthenticationPasswordRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'password' => ['required', 'confirmed', Password::defaults()],
];
if ($this->user()->password) {
$rules['current_password'] = ['required', 'string', 'current_password'];
}
return $rules;
}
}

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class PasswordUpdateRequest extends FormRequest
{
use PasswordValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
];
}
}

View file

@ -4,22 +4,35 @@
namespace App\Http\Requests\App\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileDeleteRequest extends FormRequest
{
use PasswordValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
if ($this->user()->password) {
return [
'password' => ['required', 'string', 'current_password'],
];
}
return [
'password' => $this->currentPasswordRules(),
'email_confirmation' => ['required', 'string', Rule::in([$this->user()->email])],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'email_confirmation.in' => __('settings.delete_account.email_mismatch'),
];
}
}

View file

@ -29,6 +29,7 @@ class User extends Authenticatable implements MustVerifyEmail
'name',
'email',
'password',
'google_id',
'account_id',
'current_workspace_id',
'email_verified_at',

View file

@ -32,6 +32,7 @@ public function definition(): array
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'google_id' => null,
'remember_token' => Str::random(10),
'account_id' => Account::factory(),
'current_workspace_id' => null,

View file

@ -25,7 +25,8 @@ public function up(): void
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('password')->nullable();
$table->string('google_id')->nullable()->unique();
$table->text('two_factor_secret')->nullable();
$table->text('two_factor_recovery_codes')->nullable();
$table->timestamp('two_factor_confirmed_at')->nullable();

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('password')->nullable()->change();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('password')->nullable(false)->change();
});
}
};

View file

@ -23,7 +23,7 @@
'nav' => [
'profile' => 'Profile',
'password' => 'Password',
'authentication' => 'Authentication',
'workspace' => 'Workspace',
'members' => 'Members',
'notifications' => 'Notifications',
@ -60,17 +60,48 @@
'save' => 'Save',
],
'password' => [
'title' => 'Password settings',
'heading' => 'Update password',
'description' => 'Ensure your account is using a long, random password to stay secure',
'current_password' => 'Current password',
'current_password_placeholder' => 'Current password',
'new_password' => 'New password',
'new_password_placeholder' => 'New password',
'confirm_password' => 'Confirm password',
'confirm_password_placeholder' => 'Confirm password',
'save' => 'Save password',
'authentication' => [
'title' => 'Authentication',
'page_title' => 'Authentication settings',
'sessions' => [
'title' => 'Active sessions',
'description' => 'If you notice anything suspicious, sign out of other devices.',
'unknown_browser' => 'Unknown browser',
'unknown_ip' => 'Unknown IP',
'on' => 'on',
'active_now' => 'Active now',
'log_out_others' => 'Log out other devices',
'modal_title' => 'Log out other devices',
'modal_description_password' => 'Enter your current password to confirm you want to log out other browser sessions.',
'modal_description_email' => 'Type your email address to confirm you want to log out other browser sessions.',
'password_placeholder' => 'Current password',
'email_placeholder' => 'Your account email',
'cancel' => 'Cancel',
'submit' => 'Log out other devices',
'email_mismatch' => 'The email address does not match your account.',
'flash_logged_out' => 'You have been logged out from other devices.',
],
'password' => [
'update_title' => 'Update password',
'set_title' => 'Set a password',
'update_description' => 'Ensure your account is using a long, random password to stay secure.',
'set_description' => 'Add a password so you can sign in without a connected provider.',
'current_password' => 'Current password',
'new_password' => 'New password',
'confirm_password' => 'Confirm password',
'save' => 'Save password',
'set' => 'Set password',
],
'providers' => [
'title' => 'Connected accounts',
'description' => 'Sign in faster with these connected providers.',
'connected' => 'Connected',
'not_connected' => 'Not connected',
'connect' => 'Connect',
'disconnect' => 'Disconnect',
'flash_disconnected' => ':provider disconnected successfully.',
'flash_cannot_disconnect' => 'You cannot disconnect your only sign-in method. Set a password or connect another provider first.',
],
],
'delete_account' => [
@ -80,9 +111,12 @@
'warning_message' => 'Please proceed with caution, this cannot be undone.',
'button' => 'Delete account',
'modal_title' => 'Are you sure you want to delete your account?',
'modal_description' => 'Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.',
'modal_description_password' => 'Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm.',
'modal_description_email' => 'Once your account is deleted, all of its resources and data will also be permanently deleted. Please type your email address :email to confirm.',
'password' => 'Password',
'password_placeholder' => 'Password',
'email_placeholder' => 'Your account email',
'email_mismatch' => 'The email address does not match your account.',
'cancel' => 'Cancel',
'confirm' => 'Delete account',
],

View file

@ -23,7 +23,7 @@
'nav' => [
'profile' => 'Perfil',
'password' => 'Contraseña',
'authentication' => 'Autenticación',
'workspace' => 'Workspace',
'members' => 'Miembros',
'notifications' => 'Notificaciones',
@ -60,17 +60,48 @@
'save' => 'Guardar',
],
'password' => [
'title' => 'Configuración de contraseña',
'heading' => 'Actualizar contraseña',
'description' => 'Asegúrate de que tu cuenta use una contraseña larga y aleatoria para mantenerte seguro',
'current_password' => 'Contraseña actual',
'current_password_placeholder' => 'Contraseña actual',
'new_password' => 'Nueva contraseña',
'new_password_placeholder' => 'Nueva contraseña',
'confirm_password' => 'Confirmar contraseña',
'confirm_password_placeholder' => 'Confirmar contraseña',
'save' => 'Guardar contraseña',
'authentication' => [
'title' => 'Autenticación',
'page_title' => 'Configuración de autenticación',
'sessions' => [
'title' => 'Sesiones activas',
'description' => 'Si notas algo sospechoso, cierra sesión en otros dispositivos.',
'unknown_browser' => 'Navegador desconocido',
'unknown_ip' => 'IP desconocida',
'on' => 'en',
'active_now' => 'Activa ahora',
'log_out_others' => 'Cerrar otras sesiones',
'modal_title' => 'Cerrar otras sesiones',
'modal_description_password' => 'Introduce tu contraseña actual para confirmar el cierre de las demás sesiones.',
'modal_description_email' => 'Escribe tu correo electrónico para confirmar el cierre de las demás sesiones.',
'password_placeholder' => 'Contraseña actual',
'email_placeholder' => 'Tu correo',
'cancel' => 'Cancelar',
'submit' => 'Cerrar otras sesiones',
'email_mismatch' => 'El correo electrónico no coincide con tu cuenta.',
'flash_logged_out' => 'Has cerrado sesión en los demás dispositivos.',
],
'password' => [
'update_title' => 'Actualizar contraseña',
'set_title' => 'Definir una contraseña',
'update_description' => 'Asegúrate de usar una contraseña larga y aleatoria para mantener tu cuenta segura.',
'set_description' => 'Añade una contraseña para iniciar sesión sin un proveedor conectado.',
'current_password' => 'Contraseña actual',
'new_password' => 'Nueva contraseña',
'confirm_password' => 'Confirmar contraseña',
'save' => 'Guardar contraseña',
'set' => 'Definir contraseña',
],
'providers' => [
'title' => 'Cuentas conectadas',
'description' => 'Inicia sesión más rápido con estos proveedores conectados.',
'connected' => 'Conectada',
'not_connected' => 'No conectada',
'connect' => 'Conectar',
'disconnect' => 'Desconectar',
'flash_disconnected' => ':provider desconectada correctamente.',
'flash_cannot_disconnect' => 'No puedes desconectar tu único método de inicio de sesión. Define una contraseña o conecta otro proveedor primero.',
],
],
'delete_account' => [
@ -80,9 +111,12 @@
'warning_message' => 'Procede con precaución, esta acción no se puede deshacer.',
'button' => 'Eliminar cuenta',
'modal_title' => '¿Estás seguro de que deseas eliminar tu cuenta?',
'modal_description' => 'Una vez eliminada tu cuenta, todos sus recursos y datos también se eliminarán permanentemente. Introduce tu contraseña para confirmar que deseas eliminar permanentemente tu cuenta.',
'modal_description_password' => 'Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Introduce tu contraseña para confirmar.',
'modal_description_email' => 'Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Escribe tu correo :email para confirmar.',
'password' => 'Contraseña',
'password_placeholder' => 'Contraseña',
'email_placeholder' => 'Tu correo',
'email_mismatch' => 'El correo electrónico no coincide con tu cuenta.',
'cancel' => 'Cancelar',
'confirm' => 'Eliminar cuenta',
],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,7 @@
'nav' => [
'profile' => 'Perfil',
'password' => 'Senha',
'authentication' => 'Autenticação',
'workspace' => 'Workspace',
'members' => 'Membros',
'notifications' => 'Notificações',
@ -60,17 +60,48 @@
'save' => 'Salvar',
],
'password' => [
'title' => 'Configurações de senha',
'heading' => 'Atualizar senha',
'description' => 'Certifique-se de que sua conta esteja usando uma senha longa e aleatória para se manter seguro',
'current_password' => 'Senha atual',
'current_password_placeholder' => 'Senha atual',
'new_password' => 'Nova senha',
'new_password_placeholder' => 'Nova senha',
'confirm_password' => 'Confirmar senha',
'confirm_password_placeholder' => 'Confirmar senha',
'save' => 'Salvar senha',
'authentication' => [
'title' => 'Autenticação',
'page_title' => 'Configurações de autenticação',
'sessions' => [
'title' => 'Sessões ativas',
'description' => 'Se você notar algo suspeito, encerre as sessões em outros dispositivos.',
'unknown_browser' => 'Navegador desconhecido',
'unknown_ip' => 'IP desconhecido',
'on' => 'em',
'active_now' => 'Ativa agora',
'log_out_others' => 'Encerrar outras sessões',
'modal_title' => 'Encerrar outras sessões',
'modal_description_password' => 'Digite sua senha atual para confirmar o encerramento das outras sessões.',
'modal_description_email' => 'Digite seu e-mail para confirmar o encerramento das outras sessões.',
'password_placeholder' => 'Senha atual',
'email_placeholder' => 'Seu e-mail',
'cancel' => 'Cancelar',
'submit' => 'Encerrar outras sessões',
'email_mismatch' => 'O e-mail não corresponde à sua conta.',
'flash_logged_out' => 'Outras sessões foram encerradas.',
],
'password' => [
'update_title' => 'Atualizar senha',
'set_title' => 'Definir uma senha',
'update_description' => 'Use uma senha longa e aleatória para manter sua conta segura.',
'set_description' => 'Adicione uma senha para entrar sem precisar de um provedor conectado.',
'current_password' => 'Senha atual',
'new_password' => 'Nova senha',
'confirm_password' => 'Confirmar senha',
'save' => 'Salvar senha',
'set' => 'Definir senha',
],
'providers' => [
'title' => 'Contas conectadas',
'description' => 'Faça login mais rápido usando esses provedores conectados.',
'connected' => 'Conectada',
'not_connected' => 'Não conectada',
'connect' => 'Conectar',
'disconnect' => 'Desconectar',
'flash_disconnected' => ':provider desconectada com sucesso.',
'flash_cannot_disconnect' => 'Você não pode desconectar seu único método de login. Defina uma senha ou conecte outro provedor primeiro.',
],
],
'delete_account' => [
@ -80,9 +111,12 @@
'warning_message' => 'Por favor, prossiga com cuidado, isso não pode ser desfeito.',
'button' => 'Excluir conta',
'modal_title' => 'Tem certeza que deseja excluir sua conta?',
'modal_description' => 'Uma vez que sua conta for excluída, todos os seus recursos e dados também serão permanentemente excluídos. Por favor, digite sua senha para confirmar que deseja excluir permanentemente sua conta.',
'modal_description_password' => 'Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Digite sua senha para confirmar.',
'modal_description_email' => 'Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Digite o seu e-mail :email para confirmar.',
'password' => 'Senha',
'password_placeholder' => 'Senha',
'email_placeholder' => 'Seu e-mail',
'email_mismatch' => 'O e-mail não corresponde à sua conta.',
'cancel' => 'Cancelar',
'confirm' => 'Excluir conta',
],

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { Form, usePage } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { useTemplateRef } from 'vue';
import { computed, useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/App/Settings/ProfileController';
import HeadingSmall from '@/components/HeadingSmall.vue';
@ -20,7 +20,23 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const props = defineProps<{
hasPassword: boolean;
}>();
const page = usePage();
const userEmail = computed(() => page.props.auth.user.email);
const passwordInput = useTemplateRef('passwordInput');
const emailInput = useTemplateRef('emailInput');
const focusFirstInput = () => {
if (props.hasPassword) {
passwordInput.value?.$el?.focus();
} else {
emailInput.value?.$el?.focus();
}
};
</script>
<template>
@ -48,7 +64,7 @@ const passwordInput = useTemplateRef('passwordInput');
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.$el?.focus()"
@error="focusFirstInput"
:options="{
preserveScroll: true,
}"
@ -58,11 +74,13 @@ const passwordInput = useTemplateRef('passwordInput');
<DialogHeader class="space-y-3">
<DialogTitle>{{ $t('settings.delete_account.modal_title') }}</DialogTitle>
<DialogDescription>
{{ $t('settings.delete_account.modal_description') }}
{{ hasPassword
? $t('settings.delete_account.modal_description_password')
: trans('settings.delete_account.modal_description_email', { email: userEmail }) }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<div v-if="hasPassword" class="grid gap-2">
<Label for="password" class="sr-only">
{{ $t('settings.delete_account.password') }}
</Label>
@ -76,6 +94,19 @@ const passwordInput = useTemplateRef('passwordInput');
<InputError :message="errors.password" />
</div>
<div v-else class="grid gap-2">
<Label for="email_confirmation" class="sr-only">Email</Label>
<Input
id="email_confirmation"
type="email"
name="email_confirmation"
ref="emailInput"
:placeholder="trans('settings.delete_account.email_placeholder')"
autocomplete="off"
/>
<InputError :message="errors.email_confirmation" />
</div>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button

View file

@ -0,0 +1,112 @@
/**
* Best-effort browser/OS detection from a user-agent string.
*
* Order matters: more specific identifiers (Edge, Opera, Samsung, etc.) must
* be checked before the engines they piggyback on (Chrome, Safari).
*/
export const parseBrowserName = (userAgent: string | null): string => {
if (!userAgent) {
return 'Unknown browser';
}
if (
userAgent.includes('Edg/') ||
userAgent.includes('EdgiOS/') ||
userAgent.includes('EdgA/')
) {
return 'Edge';
}
if (userAgent.includes('OPR/') || userAgent.includes('Opera/')) {
return 'Opera';
}
if (userAgent.includes('Vivaldi/')) {
return 'Vivaldi';
}
if (userAgent.includes('SamsungBrowser/')) {
return 'Samsung Internet';
}
if (userAgent.includes('UCBrowser/')) {
return 'UC Browser';
}
if (userAgent.includes('YaBrowser/')) {
return 'Yandex';
}
if (userAgent.includes('DuckDuckGo/') || userAgent.includes('Ddg/')) {
return 'DuckDuckGo';
}
if (userAgent.includes('FxiOS/') || userAgent.includes('Firefox/')) {
return 'Firefox';
}
if (userAgent.includes('CriOS/') || userAgent.includes('Chrome/')) {
return 'Chrome';
}
if (userAgent.includes('Safari/')) {
return 'Safari';
}
if (userAgent.includes('Trident/') || userAgent.includes('MSIE ')) {
return 'Internet Explorer';
}
return 'Unknown browser';
};
export const parseOsName = (userAgent: string | null): string | null => {
if (!userAgent) {
return null;
}
if (/iPhone|iPad|iPod/.test(userAgent)) {
return 'iOS';
}
if (/Android/.test(userAgent)) {
return 'Android';
}
if (/Windows Phone/.test(userAgent)) {
return 'Windows Phone';
}
if (/Windows NT/.test(userAgent)) {
return 'Windows';
}
if (/Mac OS X|Macintosh/.test(userAgent)) {
return 'macOS';
}
if (/CrOS/.test(userAgent)) {
return 'ChromeOS';
}
if (/FreeBSD|OpenBSD|NetBSD/.test(userAgent)) {
return 'BSD';
}
if (/Linux/.test(userAgent)) {
return 'Linux';
}
return null;
};
export const isMobileDevice = (userAgent: string | null): boolean => {
if (!userAgent) {
return false;
}
return /Mobile|Android|iPhone|iPad|iPod|Windows Phone|BlackBerry|BB10|webOS|Opera Mini|IEMobile/i.test(
userAgent,
);
};

View file

@ -0,0 +1,356 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { IconDeviceDesktop, IconDeviceMobile } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import AuthenticationController from '@/actions/App/Http/Controllers/App/Settings/AuthenticationController';
import DeleteUser from '@/components/DeleteUser.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/AppLayout.vue';
import { isMobileDevice, parseBrowserName, parseOsName } from '@/lib/userAgent';
import { settings as settingsHub } from '@/routes/app';
import { edit as editAuthentication } from '@/routes/app/authentication';
import { preferences as notificationPreferences } from '@/routes/app/notifications';
import { edit as editProfile } from '@/routes/app/profile';
import { redirect as googleRedirect } from '@/routes/auth/google';
import type { BreadcrumbItem } from '@/types';
type Session = {
id: string;
ip_address: string | null;
user_agent: string | null;
last_active: string;
is_current: boolean;
};
type ConnectedAccount = {
provider: 'google';
label: string;
connected: boolean;
can_disconnect: boolean;
};
const props = defineProps<{
sessions: Session[];
hasPassword: boolean;
connectedAccounts: ConnectedAccount[];
}>();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.hub.title'), href: settingsHub().url },
{ title: trans('settings.profile.title'), href: editProfile().url },
{ title: trans('settings.authentication.title') },
]);
const tabs = computed(() => [
{ name: 'profile', label: trans('settings.nav.profile'), href: editProfile().url },
{ name: 'authentication', label: trans('settings.nav.authentication'), href: editAuthentication().url },
{ name: 'notifications', label: trans('settings.nav.notifications'), href: notificationPreferences().url },
]);
const providerRedirects: Record<ConnectedAccount['provider'], () => { url: string }> = {
google: googleRedirect,
};
const passwordHeading = computed(() =>
props.hasPassword
? trans('settings.authentication.password.update_title')
: trans('settings.authentication.password.set_title'),
);
const passwordDescription = computed(() =>
props.hasPassword
? trans('settings.authentication.password.update_description')
: trans('settings.authentication.password.set_description'),
);
const logoutDialogOpen = ref(false);
</script>
<template>
<Head :title="$t('settings.authentication.page_title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
<SettingsTabsNav :tabs="tabs" active="authentication" />
<section class="space-y-12">
<div class="space-y-6">
<HeadingSmall
:title="$t('settings.authentication.sessions.title')"
:description="$t('settings.authentication.sessions.description')"
/>
<div class="space-y-2">
<div
v-for="session in sessions"
:key="session.id"
:class="[
'flex items-center gap-4 rounded-lg border p-4 transition-colors',
session.is_current
? 'border-emerald-500/30 bg-emerald-500/[0.04] dark:border-emerald-400/25 dark:bg-emerald-500/[0.06]'
: 'border-border',
]"
data-test="session-row"
>
<div
:class="[
'flex size-10 flex-shrink-0 items-center justify-center rounded-full',
session.is_current
? 'bg-emerald-500/10 text-emerald-600 dark:bg-emerald-400/10 dark:text-emerald-400'
: 'bg-muted text-muted-foreground',
]"
>
<component
:is="isMobileDevice(session.user_agent) ? IconDeviceMobile : IconDeviceDesktop"
class="size-5"
/>
</div>
<div class="flex-1 space-y-0.5">
<div class="text-sm font-medium">
{{ parseBrowserName(session.user_agent) }}
<span
v-if="parseOsName(session.user_agent)"
class="font-normal text-muted-foreground"
>
{{ $t('settings.authentication.sessions.on') }} {{ parseOsName(session.user_agent) }}
</span>
</div>
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{{ session.ip_address ?? $t('settings.authentication.sessions.unknown_ip') }}</span>
<span aria-hidden="true">·</span>
<template v-if="session.is_current">
<span class="relative flex size-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60"
/>
<span class="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
<span class="font-medium text-emerald-700 dark:text-emerald-400">
{{ $t('settings.authentication.sessions.active_now') }}
</span>
</template>
<template v-else>
<span>{{ session.last_active }}</span>
</template>
</div>
</div>
</div>
</div>
<Dialog v-model:open="logoutDialogOpen">
<DialogTrigger as-child>
<Button
variant="outline"
data-test="log-out-other-sessions-button"
:disabled="sessions.length <= 1"
>
{{ $t('settings.authentication.sessions.log_out_others') }}
</Button>
</DialogTrigger>
<DialogContent>
<Form
v-bind="AuthenticationController.destroyOtherSessions.form()"
:options="{ preserveScroll: true }"
reset-on-success
@success="logoutDialogOpen = false"
class="space-y-6"
v-slot="{ errors, processing }"
>
<DialogHeader>
<DialogTitle>{{ $t('settings.authentication.sessions.modal_title') }}</DialogTitle>
<DialogDescription>
{{ hasPassword
? $t('settings.authentication.sessions.modal_description_password')
: $t('settings.authentication.sessions.modal_description_email') }}
</DialogDescription>
</DialogHeader>
<div v-if="hasPassword" class="grid gap-2">
<Label for="session_password" class="sr-only">
{{ $t('settings.authentication.password.current_password') }}
</Label>
<Input
id="session_password"
type="password"
name="password"
:placeholder="trans('settings.authentication.sessions.password_placeholder')"
/>
<InputError :message="errors.password" />
</div>
<div v-else class="grid gap-2">
<Label for="session_email_confirmation" class="sr-only">
Email
</Label>
<Input
id="session_email_confirmation"
type="email"
name="email_confirmation"
:placeholder="trans('settings.authentication.sessions.email_placeholder')"
autocomplete="off"
/>
<InputError :message="errors.email_confirmation" />
</div>
<DialogFooter class="gap-2">
<Button type="submit" :disabled="processing">
{{ $t('settings.authentication.sessions.submit') }}
</Button>
<DialogClose as-child>
<Button variant="secondary">
{{ $t('settings.authentication.sessions.cancel') }}
</Button>
</DialogClose>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
<Separator />
<div class="space-y-6">
<HeadingSmall :title="passwordHeading" :description="passwordDescription" />
<Form
v-bind="AuthenticationController.updatePassword.form()"
:options="{ preserveScroll: true }"
reset-on-success
:reset-on-error="['password', 'password_confirmation', 'current_password']"
class="space-y-6"
v-slot="{ errors, processing }"
>
<div v-if="hasPassword" class="grid gap-2">
<Label for="current_password">{{ $t('settings.authentication.password.current_password') }}</Label>
<Input
id="current_password"
name="current_password"
type="password"
autocomplete="current-password"
/>
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label for="password">{{ $t('settings.authentication.password.new_password') }}</Label>
<Input
id="password"
name="password"
type="password"
autocomplete="new-password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation">{{ $t('settings.authentication.password.confirm_password') }}</Label>
<Input
id="password_confirmation"
name="password_confirmation"
type="password"
autocomplete="new-password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<Button :disabled="processing" data-test="update-password-button">
{{ hasPassword
? $t('settings.authentication.password.save')
: $t('settings.authentication.password.set') }}
</Button>
</Form>
</div>
<Separator />
<div class="space-y-6">
<HeadingSmall
:title="$t('settings.authentication.providers.title')"
:description="$t('settings.authentication.providers.description')"
/>
<div class="space-y-2">
<div
v-for="account in connectedAccounts"
:key="account.provider"
class="flex items-center gap-4 rounded-lg border p-4"
:data-test="`connected-account-${account.provider}`"
>
<div class="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-muted">
<img
:src="`/images/social/${account.provider}.svg`"
:alt="account.label"
class="size-5"
/>
</div>
<div class="flex-1 space-y-0.5">
<div class="text-sm font-medium">{{ account.label }}</div>
<div
v-if="account.connected"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<span class="size-1.5 rounded-full bg-emerald-500" />
<span>{{ $t('settings.authentication.providers.connected') }}</span>
</div>
<div v-else class="text-xs text-muted-foreground">
{{ $t('settings.authentication.providers.not_connected') }}
</div>
</div>
<Form
v-if="account.connected && account.can_disconnect"
v-bind="AuthenticationController.disconnectProvider.form(account.provider)"
:options="{ preserveScroll: true }"
#default="{ processing }"
>
<Button
type="submit"
variant="ghost"
size="sm"
:disabled="processing"
class="text-muted-foreground hover:text-destructive"
:data-test="`disconnect-${account.provider}`"
>
{{ $t('settings.authentication.providers.disconnect') }}
</Button>
</Form>
<Link
v-else-if="!account.connected"
:href="providerRedirects[account.provider]().url"
>
<Button
variant="outline"
size="sm"
:data-test="`connect-${account.provider}`"
>
{{ $t('settings.authentication.providers.connect') }}
</Button>
</Link>
</div>
</div>
</div>
<Separator />
<DeleteUser :has-password="hasPassword" />
</section>
</div>
</AppLayout>
</template>

View file

@ -12,7 +12,7 @@ import AppLayout from '@/layouts/AppLayout.vue';
import { settings as settingsHub } from '@/routes/app';
import { preferences as preferencesRoute } from '@/routes/app/notifications';
import { edit as editProfile } from '@/routes/app/profile';
import { edit as editPassword } from '@/routes/app/user-password';
import { edit as editAuthentication } from '@/routes/app/authentication';
import type { BreadcrumbItem } from '@/types';
interface Preferences {
@ -40,7 +40,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
const tabs = computed(() => [
{ name: 'profile', label: trans('settings.nav.profile'), href: editProfile().url },
{ name: 'password', label: trans('settings.nav.password'), href: editPassword().url },
{ name: 'authentication', label: trans('settings.nav.authentication'), href: editAuthentication().url },
{ name: 'notifications', label: trans('settings.nav.notifications'), href: preferencesRoute().url },
]);

View file

@ -1,108 +0,0 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import PasswordController from '@/actions/App/Http/Controllers/App/Settings/PasswordController';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { settings as settingsHub } from '@/routes/app';
import { preferences as notificationPreferences } from '@/routes/app/notifications';
import { edit as editProfile } from '@/routes/app/profile';
import { edit as editPassword } from '@/routes/app/user-password';
import type { BreadcrumbItem } from '@/types';
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('settings.hub.title'), href: settingsHub().url },
{ title: trans('settings.profile.title'), href: editProfile().url },
{ title: trans('settings.nav.password') },
]);
const tabs = computed(() => [
{ name: 'profile', label: trans('settings.nav.profile'), href: editProfile().url },
{ name: 'password', label: trans('settings.nav.password'), href: editPassword().url },
{ name: 'notifications', label: trans('settings.nav.notifications'), href: notificationPreferences().url },
]);
</script>
<template>
<Head :title="$t('settings.password.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="mx-auto max-w-4xl space-y-6 px-4 py-6">
<SettingsTabsNav :tabs="tabs" active="password" />
<section class="space-y-12">
<div class="space-y-6">
<HeadingSmall
:title="$t('settings.password.heading')"
:description="$t('settings.password.description')"
/>
<Form
v-bind="PasswordController.update.form()"
:options="{
preserveScroll: true,
}"
reset-on-success
:reset-on-error="[
'password',
'password_confirmation',
'current_password',
]"
class="space-y-6"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="current_password">{{ $t('settings.password.current_password') }}</Label>
<Input
id="current_password"
name="current_password"
type="password"
autocomplete="current-password"
:placeholder="trans('settings.password.current_password_placeholder')"
/>
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label for="password">{{ $t('settings.password.new_password') }}</Label>
<Input
id="password"
name="password"
type="password"
autocomplete="new-password"
:placeholder="trans('settings.password.new_password_placeholder')"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation">{{ $t('settings.password.confirm_password') }}</Label>
<Input
id="password_confirmation"
name="password_confirmation"
type="password"
autocomplete="new-password"
:placeholder="trans('settings.password.confirm_password_placeholder')"
/>
<InputError :message="errors.password_confirmation" />
</div>
<Button
:disabled="processing"
data-test="update-password-button"
>
{{ $t('settings.password.save') }}
</Button>
</Form>
</div>
</section>
</div>
</AppLayout>
</template>

View file

@ -4,7 +4,6 @@ import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/App/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import PhotoUpload from '@/components/PhotoUpload.vue';
@ -15,9 +14,9 @@ import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/AppLayout.vue';
import { settings as settingsHub } from '@/routes/app';
import { edit as editAuthentication } from '@/routes/app/authentication';
import { preferences as notificationPreferences } from '@/routes/app/notifications';
import { deletePhoto, edit as editProfile, uploadPhoto } from '@/routes/app/profile';
import { edit as editPassword } from '@/routes/app/user-password';
import { send } from '@/routes/verification';
import type { BreadcrumbItem } from '@/types';
@ -38,7 +37,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
const tabs = computed(() => [
{ name: 'profile', label: trans('settings.nav.profile'), href: editProfile().url },
{ name: 'password', label: trans('settings.nav.password'), href: editPassword().url },
{ name: 'authentication', label: trans('settings.nav.authentication'), href: editAuthentication().url },
{ name: 'notifications', label: trans('settings.nav.notifications'), href: notificationPreferences().url },
]);
</script>
@ -132,10 +131,6 @@ const tabs = computed(() => [
</Button>
</Form>
</div>
<Separator />
<DeleteUser />
</section>
</div>
</AppLayout>

View file

@ -16,8 +16,8 @@
use App\Http\Controllers\App\PostTemplateController;
use App\Http\Controllers\App\PresenceController;
use App\Http\Controllers\App\Settings\AccountController;
use App\Http\Controllers\App\Settings\AuthenticationController;
use App\Http\Controllers\App\Settings\NotificationPreferenceController;
use App\Http\Controllers\App\Settings\PasswordController;
use App\Http\Controllers\App\Settings\ProfileController;
use App\Http\Controllers\App\Settings\SettingsController;
use App\Http\Controllers\App\Settings\UsageController;
@ -241,10 +241,16 @@
Route::middleware(['auth'])->group(function () {
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('app.profile.destroy');
Route::get('settings/profile/password', [PasswordController::class, 'edit'])->name('app.user-password.edit');
Route::put('settings/profile/password', [PasswordController::class, 'update'])
Route::get('settings/authentication', [AuthenticationController::class, 'edit'])->name('app.authentication.edit');
Route::put('settings/authentication/password', [AuthenticationController::class, 'updatePassword'])
->middleware('throttle:6,1')
->name('app.user-password.update');
->name('app.authentication.update-password');
Route::delete('settings/authentication/sessions', [AuthenticationController::class, 'destroyOtherSessions'])
->name('app.authentication.destroy-other-sessions');
Route::delete('settings/authentication/providers/{provider}', [AuthenticationController::class, 'disconnectProvider'])
->name('app.authentication.disconnect-provider');
Route::get('settings/profile/notifications', [NotificationPreferenceController::class, 'edit'])->name('app.notifications.preferences');
Route::put('settings/profile/notifications', [NotificationPreferenceController::class, 'update'])->name('app.notifications.preferences.update');
});

View file

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
test('authentication page is displayed', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('app.authentication.edit'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('settings/profile/Authentication')
->has('sessions')
->where('hasPassword', true)
->has('connectedAccounts')
);
});
test('password can be updated', function () {
$user = User::factory()->create();
$this->actingAs($user)
->from(route('app.authentication.edit'))
->put(route('app.authentication.update-password'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])
->assertSessionHasNoErrors()
->assertRedirect(route('app.authentication.edit'));
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
test('correct password must be provided to update password', function () {
$user = User::factory()->create();
$this->actingAs($user)
->from(route('app.authentication.edit'))
->put(route('app.authentication.update-password'), [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])
->assertSessionHasErrors('current_password')
->assertRedirect(route('app.authentication.edit'));
});
test('password update requires authentication', function () {
$this->put(route('app.authentication.update-password'), [])
->assertRedirect(route('login'));
});
test('password must be confirmed', function () {
$user = User::factory()->create();
$this->actingAs($user)
->from(route('app.authentication.edit'))
->put(route('app.authentication.update-password'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'wrong-confirmation',
])
->assertSessionHasErrors('password');
});
test('user without a password can set one without current_password', function () {
$user = User::factory()->create(['password' => null, 'google_id' => 'google-123']);
$this->actingAs($user)
->from(route('app.authentication.edit'))
->put(route('app.authentication.update-password'), [
'password' => 'new-password',
'password_confirmation' => 'new-password',
])
->assertSessionHasNoErrors();
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
test('disconnect provider removes the link', function () {
$user = User::factory()->create(['google_id' => 'google-123', 'password' => bcrypt('password')]);
$this->actingAs($user)
->from(route('app.authentication.edit'))
->delete(route('app.authentication.disconnect-provider', 'google'))
->assertRedirect(route('app.authentication.edit'));
expect($user->refresh()->google_id)->toBeNull();
});
test('disconnect provider blocked when it is the only sign-in method', function () {
$user = User::factory()->create(['google_id' => 'google-123', 'password' => null]);
$this->actingAs($user)
->from(route('app.authentication.edit'))
->delete(route('app.authentication.disconnect-provider', 'google'))
->assertSessionHas('flash.error');
expect($user->refresh()->google_id)->toBe('google-123');
});
test('disconnect provider rejects unknown provider', function () {
$user = User::factory()->create();
$this->actingAs($user)
->delete(route('app.authentication.disconnect-provider', 'twitter'))
->assertNotFound();
});
test('destroy other sessions removes other rows for the user', function () {
if (config('session.driver') !== 'database') {
$this->markTestSkipped('Session driver is not database.');
}
$user = User::factory()->create();
$sessionsTable = config('session.table', 'sessions');
DB::table($sessionsTable)->insert([
[
'id' => 'other-session-id',
'user_id' => $user->id,
'ip_address' => '1.2.3.4',
'user_agent' => 'OtherDevice',
'payload' => '',
'last_activity' => time(),
],
]);
$this->actingAs($user)
->from(route('app.authentication.edit'))
->delete(route('app.authentication.destroy-other-sessions'), [
'password' => 'password',
])
->assertRedirect(route('app.authentication.edit'));
expect(DB::table($sessionsTable)->where('id', 'other-session-id')->exists())->toBeFalse();
});

View file

@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Support\Facades\Hash;
test('password update page is displayed', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get(route('app.user-password.edit'));
$response->assertOk();
});
test('password can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('app.user-password.edit'))
->put(route('app.user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('app.user-password.edit'));
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
test('correct password must be provided to update password', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('app.user-password.edit'))
->put(route('app.user-password.update'), [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrors('current_password')
->assertRedirect(route('app.user-password.edit'));
});
test('password update requires authentication', function () {
$response = $this->put(route('app.user-password.update'), []);
$response->assertRedirect(route('login'));
});
test('password must be confirmed', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('app.user-password.edit'))
->put(route('app.user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'wrong-confirmation',
]);
$response->assertSessionHasErrors('password');
});