Refactor security settings page and update password functionality

- Translated UI elements to Indonesian for better localization.
- Improved layout by introducing Card components for better visual structure.
- Removed unnecessary imports and props related to passkeys and two-factor authentication.
- Updated tests to cover new password update scenarios and validation rules.
- Ensured proper redirection and error handling for unauthorized access to security settings.
- Enhanced user experience by adding success flash messages on password updates.
This commit is contained in:
Yoga Pangestu 2026-07-30 22:08:24 +07:00
parent 6dbe9da581
commit aecd8eaf1f
14 changed files with 1407 additions and 328 deletions

View File

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class PermissionController extends Controller
{
public function edit(): Response
{
return Inertia::render('settings/permissions');
}
}

View File

@ -21,6 +21,18 @@ public function edit(Request $request): Response
$user->load('userProfile');
return Inertia::render('settings/profile', [
'user' => [
'id' => $user->id,
'email' => $user->email,
'username' => $user->username,
'userProfile' => $user->userProfile ? [
'full_name' => $user->userProfile->full_name,
'phone_number' => $user->userProfile->phone_number,
'gender' => $user->userProfile->gender?->value,
'birth_date' => $user->userProfile->birth_date?->format('Y-m-d'),
'address' => $user->userProfile->address,
] : null,
],
'mustVerifyEmail' => $user instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
@ -34,7 +46,10 @@ public function update(ProfileUpdateRequest $request): RedirectResponse
$validated = $request->validated();
$user = $request->user();
$user->fill(['email' => $validated['email']]);
$user->fill([
'email' => $validated['email'],
'username' => $validated['username'],
]);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
@ -45,7 +60,7 @@ public function update(ProfileUpdateRequest $request): RedirectResponse
$user->userProfile()->updateOrCreate(
[],
[
'full_name' => $validated['name'],
'full_name' => $validated['full_name'],
'phone_number' => $validated['phone_number'] ?? null,
'gender' => $validated['gender'] ?? null,
'birth_date' => $validated['birth_date'] ?? null,
@ -53,7 +68,7 @@ public function update(ProfileUpdateRequest $request): RedirectResponse
],
);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Profil berhasil diperbarui.']);
return to_route('profile.edit');
}

View File

@ -4,49 +4,22 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\PasswordUpdateRequest;
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
class SecurityController extends Controller
{
/**
* Show the user's security settings page.
*/
public function edit(TwoFactorAuthenticationRequest $request): Response
public function edit(): Response
{
$props = [
'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(),
'canManagePasskeys' => Features::canManagePasskeys(),
'passkeys' => Features::canManagePasskeys()
? $request->user()
->passkeys()
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
->latest()
->get()
->map(fn ($passkey) => [
'id' => $passkey->id,
'name' => $passkey->name,
'authenticator' => $passkey->authenticator,
'created_at_diff' => $passkey->created_at->diffForHumans(),
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
])
->values()
->all()
: [],
'passwordRules' => Password::defaults()->toPasswordRulesString(),
];
if (Features::canManageTwoFactorAuthentication()) {
$request->ensureStateIsValid();
$props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication();
$props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
return Inertia::render('settings/security', $props);
}
@ -59,7 +32,8 @@ public function update(PasswordUpdateRequest $request): RedirectResponse
'password' => $request->password,
]);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kata sandi berhasil diperbarui.']);
return back();
}

View File

@ -2,21 +2,40 @@
namespace App\Http\Requests\Settings;
use App\Concerns\ProfileValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
use ProfileValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return $this->profileRules($this->user()->id);
$userId = $this->user()->id;
return [
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($userId),
],
'username' => [
'required',
'string',
'max:20',
'alpha_dash',
Rule::unique('users', 'username')->ignore($userId),
],
'full_name' => ['required', 'string', 'max:200'],
'phone_number' => ['nullable', 'string', 'max:20'],
'gender' => ['nullable', 'in:male,female'],
'birth_date' => ['nullable', 'date'],
'address' => ['nullable', 'string'],
];
}
}

View File

@ -1,5 +1,3 @@
import { Link, router } from '@inertiajs/react';
import { LogOut, Settings } from 'lucide-react';
import {
DropdownMenuGroup,
DropdownMenuItem,
@ -11,6 +9,8 @@ import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
import { logout } from '@/routes';
import { edit } from '@/routes/profile';
import type { User } from '@/types';
import { Link, router } from '@inertiajs/react';
import { LogOut, Settings } from 'lucide-react';
type Props = {
user: User;
@ -41,7 +41,7 @@ export function UserMenuContent({ user }: Props) {
onClick={cleanup}
>
<Settings className="mr-2" />
Settings
Pengaturan
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
@ -55,7 +55,7 @@ export function UserMenuContent({ user }: Props) {
data-test="logout-button"
>
<LogOut className="mr-2" />
Log out
Keluar
</Link>
</DropdownMenuItem>
</>

View File

@ -1,31 +1,37 @@
import { Link } from '@inertiajs/react';
import type { PropsWithChildren } from 'react';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { cn, toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance';
import { edit as editPermissions } from '@/routes/permissions';
import { edit } from '@/routes/profile';
import { edit as editSecurity } from '@/routes/security';
import type { NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import type { PropsWithChildren } from 'react';
const sidebarNavItems: NavItem[] = [
{
title: 'Profile',
title: 'Profil',
href: edit(),
icon: null,
},
{
title: 'Security',
title: 'Kata Sandi',
href: editSecurity(),
icon: null,
},
{
title: 'Appearance',
title: 'Tampilan',
href: editAppearance(),
icon: null,
},
{
title: 'Izin',
href: editPermissions(),
icon: null,
},
];
export default function SettingsLayout({ children }: PropsWithChildren) {
@ -34,8 +40,8 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
return (
<div className="px-4 py-6">
<Heading
title="Settings"
description="Manage your profile and account settings"
title="Pengaturan"
description="Kelola pengaturan akun Anda seperti akun, data pribadi dan lainnya."
/>
<div className="flex flex-col lg:flex-row lg:space-x-12">
@ -67,11 +73,9 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
<Separator className="my-6 lg:hidden" />
<div className="flex-1 md:max-w-2xl">
<section className="max-w-xl space-y-12">
{children}
</section>
</div>
<section className="flex-1 space-y-12">
{children}
</section>
</div>
</div>
);

View File

@ -1,4 +1,3 @@
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import { Button } from '@/components/ui/button';
@ -7,6 +6,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { store } from '@/routes/login';
import { Form, Head } from '@inertiajs/react';
export default function Login() {
return (
@ -14,7 +14,7 @@ export default function Login() {
<Head title="Masuk Akun" />
<Form
{...store.form()}
action={store()}
resetOnSuccess={['password']}
className="flex flex-col gap-6"
>

View File

@ -1,21 +1,15 @@
import { Head } from '@inertiajs/react';
import AppearanceTabs from '@/components/appearance-tabs';
import Heading from '@/components/heading';
import { edit as editAppearance } from '@/routes/appearance';
import { Head } from '@inertiajs/react';
export default function Appearance() {
return (
<>
<Head title="Appearance settings" />
<Head title="Tampilan" />
<h1 className="sr-only">Appearance settings</h1>
<h1 className="sr-only">Tampilan</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Appearance settings"
description="Update the appearance settings for your account"
/>
<div className="space-y-6 px-4 md:px-6">
<AppearanceTabs />
</div>
</>
@ -25,7 +19,7 @@ export default function Appearance() {
Appearance.layout = {
breadcrumbs: [
{
title: 'Appearance settings',
title: 'Tampilan',
href: editAppearance(),
},
],

View File

@ -0,0 +1,188 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Switch } from '@/components/ui/switch';
import { edit } from '@/routes/permissions';
import { Head } from '@inertiajs/react';
import { Bell, Camera, MapPin } from 'lucide-react';
import { useEffect, useState } from 'react';
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
export default function Permissions() {
const [notifications, setNotifications] = useState<PermissionStatus>('unknown');
const [camera, setCamera] = useState<PermissionStatus>('unknown');
const [location, setLocation] = useState<PermissionStatus>('unknown');
useEffect(() => {
if ('Notification' in window) {
if (Notification.permission === 'granted') setNotifications('granted');
else if (Notification.permission === 'denied') setNotifications('denied');
else setNotifications('prompt');
}
if (navigator.mediaDevices) {
navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => {
stream.getTracks().forEach((track) => track.stop());
setCamera('granted');
}).catch(() => {
if ('permissions' in navigator) {
navigator.permissions.query({ name: 'camera' as PermissionName }).then((result) => {
setCamera(result.state as PermissionStatus);
}).catch(() => setCamera('denied'));
} else {
setCamera('denied');
}
});
} else {
setCamera('denied');
}
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
() => setLocation('granted'),
() => {
if ('permissions' in navigator) {
navigator.permissions.query({ name: 'geolocation' }).then((result) => {
setLocation(result.state as PermissionStatus);
}).catch(() => setLocation('denied'));
} else {
setLocation('denied');
}
},
);
} else {
setLocation('denied');
}
}, []);
const handleNotifications = async (checked: boolean) => {
if (checked) {
if (!('Notification' in window)) return;
if (Notification.permission === 'denied') {
setNotifications('denied');
return;
}
const result = await Notification.requestPermission();
setNotifications(result === 'granted' ? 'granted' : result === 'denied' ? 'denied' : 'prompt');
} else {
setNotifications('prompt');
}
};
const handleCamera = async (checked: boolean) => {
if (checked) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((track) => track.stop());
setCamera('granted');
} catch {
setCamera('denied');
}
} else {
setCamera('prompt');
}
};
const handleLocation = async (checked: boolean) => {
if (checked) {
navigator.geolocation.getCurrentPosition(
() => setLocation('granted'),
() => setLocation('denied'),
);
} else {
setLocation('prompt');
}
};
const isDenied = (status: PermissionStatus) => status === 'denied';
return (
<>
<Head title="Izin" />
<h1 className="sr-only">Izin</h1>
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto px-4 md:px-6">
<Alert variant={isDenied(notifications) ? 'destructive' : 'default'}>
<Bell className="h-4 w-4" />
<div className="flex items-center justify-between">
<div className="flex-1">
<AlertTitle>Notifikasi</AlertTitle>
<AlertDescription>
Izinkan aplikasi mengirimkan notifikasi push ke perangkat Anda.
</AlertDescription>
{isDenied(notifications) && (
<p className="mt-1 text-sm text-destructive">
Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.
</p>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={notifications === 'granted'}
onCheckedChange={handleNotifications}
disabled={isDenied(notifications)}
/>
</div>
</div>
</Alert>
<Alert variant={isDenied(camera) ? 'destructive' : 'default'}>
<Camera className="h-4 w-4" />
<div className="flex items-center justify-between">
<div className="flex-1">
<AlertTitle>Kamera</AlertTitle>
<AlertDescription>
Izinkan aplikasi mengakses kamera perangkat Anda untuk mengambil foto atau video.
</AlertDescription>
{isDenied(camera) && (
<p className="mt-1 text-sm text-destructive">
Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.
</p>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={camera === 'granted'}
onCheckedChange={handleCamera}
disabled={isDenied(camera)}
/>
</div>
</div>
</Alert>
<Alert variant={isDenied(location) ? 'destructive' : 'default'}>
<MapPin className="h-4 w-4" />
<div className="flex items-center justify-between">
<div className="flex-1">
<AlertTitle>Lokasi</AlertTitle>
<AlertDescription>
Izinkan aplikasi mengakses lokasi perangkat Anda untuk menyediakan layanan berbasis lokasi.
</AlertDescription>
{isDenied(location) && (
<p className="mt-1 text-sm text-destructive">
Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya.
</p>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={location === 'granted'}
onCheckedChange={handleLocation}
disabled={isDenied(location)}
/>
</div>
</div>
</Alert>
</div>
</>
);
}
Permissions.layout = {
breadcrumbs: [
{
title: 'Izin',
href: edit(),
},
],
};

View File

@ -1,129 +1,163 @@
import { Form, Head, usePage } from '@inertiajs/react';
import { Link } from '@inertiajs/react';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/delete-user';
import Heading from '@/components/heading';
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { PhoneNumberInput } from '@/components/phone-number-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
import type { Auth } from '@/types';
import { Form, Head } from '@inertiajs/react';
import { useState } from 'react';
type PageProps = {
auth: Auth;
type UserData = {
id: number;
email: string;
username: string;
userProfile: {
full_name: string;
phone_number: string | null;
gender: string | null;
birth_date: string | null;
address: string | null;
} | null;
};
export default function Profile({
mustVerifyEmail,
status,
}: {
type Props = {
user: UserData;
mustVerifyEmail: boolean;
status?: string;
}) {
const { auth } = usePage<PageProps>().props;
};
export default function Profile({ user }: Props) {
const [birthDate, setBirthDate] = useState<Date | undefined>(
user.userProfile?.birth_date ? new Date(user.userProfile.birth_date) : undefined
);
return (
<>
<Head title="Profile settings" />
<h1 className="sr-only">Profile settings</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Profile"
description="Update your name and email address"
/>
<Head title="Profil" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto px-4 md:px-6">
<Form
{...ProfileController.update.form()}
options={{
preserveScroll: true,
}}
className="space-y-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="mt-1 block w-full"
defaultValue={auth.user.name}
name="name"
required
autoComplete="name"
placeholder="Full name"
/>
<InputError
className="mt-2"
message={errors.name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
className="mt-1 block w-full"
defaultValue={auth.user.email}
name="email"
required
autoComplete="username"
placeholder="Email address"
/>
<InputError
className="mt-2"
message={errors.email}
/>
</div>
{mustVerifyEmail &&
auth.user.email_verified_at === null && (
<div>
<p className="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.{' '}
<Link
href={send()}
as="button"
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to re-send the
verification email.
</Link>
</p>
{status ===
'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">
A new verification link has been
sent to your email address.
</div>
)}
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Akun</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
Email <span className="text-destructive">*</span>
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="Masukkan email"
defaultValue={user.email}
/>
<InputError message={errors.email} />
</div>
)}
<div className="grid gap-2">
<Label htmlFor="username">
Username <span className="text-destructive">*</span>
</Label>
<Input
id="username"
name="username"
placeholder="Masukkan username"
defaultValue={user.username}
/>
<InputError message={errors.username} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profil</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap <span className="text-destructive">*</span>
</Label>
<Input
id="full_name"
name="full_name"
placeholder="Masukkan nama lengkap"
defaultValue={user.userProfile?.full_name ?? ''}
/>
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">No. Telepon</Label>
<PhoneNumberInput
name="phone_number"
defaultValue={user.userProfile?.phone_number ?? ''}
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-2">
<Label>Jenis Kelamin</Label>
<RadioGroup
name="gender"
defaultValue={user.userProfile?.gender ?? ''}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="settings-gender-male" />
<Label htmlFor="settings-gender-male" className="font-normal">Laki-laki</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="settings-gender-female" />
<Label htmlFor="settings-gender-female" className="font-normal">Perempuan</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>Tanggal Lahir</Label>
<DatePicker
name="birth_date"
value={birthDate}
onChange={setBirthDate}
placeholder="Pilih tanggal lahir"
/>
<InputError message={errors.birth_date} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">Alamat</Label>
<Textarea
id="address"
name="address"
placeholder="Masukkan alamat"
rows={3}
defaultValue={user.userProfile?.address ?? ''}
/>
<InputError message={errors.address} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button
disabled={processing}
data-test="update-profile-button"
>
Save
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</>
</div>
)}
</Form>
</div>
<DeleteUser />
</>
);
}
@ -131,7 +165,7 @@ export default function Profile({
Profile.layout = {
breadcrumbs: [
{
title: 'Profile settings',
title: 'Profil',
href: edit(),
},
],

View File

@ -1,21 +1,16 @@
import { Form, Head } from '@inertiajs/react';
import { useRef } from 'react';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import type { Props as ManagePasskeysProps } from '@/components/manage-passkeys';
import ManagePasskeys from '@/components/manage-passkeys';
import type { Props as ManageTwoFactorProps } from '@/components/manage-two-factor';
import ManageTwoFactor from '@/components/manage-two-factor';
import PasswordInput from '@/components/password-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { edit } from '@/routes/security';
import { Form, Head } from '@inertiajs/react';
import { useRef } from 'react';
type Props = {
passwordRules: string;
} & ManagePasskeysProps &
ManageTwoFactorProps;
};
export default function Security(props: Props) {
const passwordInput = useRef<HTMLInputElement>(null);
@ -23,27 +18,16 @@ export default function Security(props: Props) {
return (
<>
<Head title="Security settings" />
<Head title="Kata Sandi" />
<h1 className="sr-only">Security settings</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
/>
<h1 className="sr-only">Kata Sandi</h1>
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto px-4 md:px-6">
<Form
{...SecurityController.update.form()}
options={{
preserveScroll: true,
}}
resetOnError={[
'password',
'password_confirmation',
'current_password',
]}
resetOnSuccess
onError={(errors) => {
if (errors.password) {
@ -54,85 +38,80 @@ export default function Security(props: Props) {
currentPasswordInput.current?.focus();
}
}}
className="space-y-6"
>
{({ errors, processing }) => (
<>
<div className="grid gap-2">
<Label htmlFor="current_password">
Current password
</Label>
<div className="grid gap-6">
<Card>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="current_password">
Kata Sandi Saat <span className="text-destructive">*</span>
</Label>
<PasswordInput
id="current_password"
ref={currentPasswordInput}
name="current_password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
/>
<PasswordInput
id="current_password"
ref={currentPasswordInput}
name="current_password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Masukkan kata sandi saat ini"
/>
<InputError message={errors.current_password} />
</div>
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<div className="grid gap-2">
<Label htmlFor="password">
Kata Sandi Baru <span className="text-destructive">*</span>
</Label>
<PasswordInput
id="password"
ref={passwordInput}
name="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
passwordrules={props.passwordRules}
/>
<PasswordInput
id="password"
ref={passwordInput}
name="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Masukkan kata sandi baru"
passwordrules={props.passwordRules}
/>
<InputError message={errors.password} />
</div>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
</Label>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Konfirmasi Kata Sandi <span className="text-destructive">*</span>
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
passwordrules={props.passwordRules}
/>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Ulangi kata sandi baru"
passwordrules={props.passwordRules}
/>
<InputError
message={errors.password_confirmation}
/>
</div>
<InputError
message={errors.password_confirmation}
/>
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button
type="submit"
disabled={processing}
data-test="update-password-button"
>
Save
Simpan
</Button>
</div>
</>
</div>
)}
</Form>
</div>
<ManageTwoFactor
canManageTwoFactor={props.canManageTwoFactor}
requiresConfirmation={props.requiresConfirmation}
twoFactorEnabled={props.twoFactorEnabled}
/>
<ManagePasskeys
canManagePasskeys={props.canManagePasskeys}
passkeys={props.passkeys}
/>
</>
);
}
@ -140,7 +119,7 @@ export default function Security(props: Props) {
Security.layout = {
breadcrumbs: [
{
title: 'Security settings',
title: 'Kata Sandi',
href: edit(),
},
],

View File

@ -1,8 +1,8 @@
<?php
use App\Http\Controllers\Settings\PermissionController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\SecurityController;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->group(function () {
@ -13,10 +13,7 @@
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('settings/security', [SecurityController::class, 'edit'])
->middleware(RequirePassword::class)
->name('security.edit');
Route::put('settings/password', [SecurityController::class, 'update'])
@ -24,6 +21,9 @@
->name('user-password.update');
Route::inertia('settings/appearance', 'settings/appearance')->name('appearance.edit');
Route::get('settings/permissions', [PermissionController::class, 'edit'])
->name('permissions.edit');
});
Route::get('.well-known/passkey-endpoints', function () {

View File

@ -1,6 +1,37 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| AUTHENTICATION
|--------------------------------------------------------------------------
*/
test('guests are redirected to the login page for profile edit', function () {
$response = $this->get(route('profile.edit'));
$response->assertRedirect(route('login'));
});
test('guests are redirected to the login page for profile update', function () {
$response = $this->patch(route('profile.update'), [
'email' => 'test@example.com',
'username' => 'testuser',
'full_name' => 'Test User',
]);
$response->assertRedirect(route('login'));
});
/*
|--------------------------------------------------------------------------
| PROFILE EDIT PAGE
|--------------------------------------------------------------------------
*/
test('profile page is displayed', function () {
$user = User::factory()->create();
@ -12,14 +43,85 @@
$response->assertOk();
});
test('profile information can be updated', function () {
test('profile page renders correct inertia component', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('profile.edit'))
->assertInertia(fn (Assert $page) => $page
->component('settings/profile')
);
});
test('profile page passes user data to view', function () {
$user = User::factory()->create([
'email' => 'john@example.com',
'username' => 'johndoe',
]);
$user->userProfile()->create([
'full_name' => 'John Doe',
'phone_number' => '08123456789',
'gender' => 'male',
'birth_date' => '1990-05-15',
'address' => 'Jl. Sudirman No. 1',
]);
$this->actingAs($user)
->get(route('profile.edit'))
->assertInertia(fn (Assert $page) => $page
->where('user.id', $user->id)
->where('user.email', 'john@example.com')
->where('user.username', 'johndoe')
->where('user.userProfile.full_name', 'John Doe')
->where('user.userProfile.phone_number', '08123456789')
->where('user.userProfile.gender', 'male')
->where('user.userProfile.birth_date', '1990-05-15')
->where('user.userProfile.address', 'Jl. Sudirman No. 1')
);
});
test('profile page works when user has no profile', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('profile.edit'))
->assertInertia(fn (Assert $page) => $page
->where('user.id', $user->id)
->where('user.email', $user->email)
->where('user.username', $user->username)
->where('user.userProfile', null)
);
});
test('profile page passes mustVerifyEmail flag', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('profile.edit'))
->assertInertia(fn (Assert $page) => $page
->has('mustVerifyEmail')
);
});
/*
|--------------------------------------------------------------------------
| PROFILE UPDATE - SUCCESS
|--------------------------------------------------------------------------
*/
test('profile information can be updated with all fields', function () {
$user = User::factory()->create(['username' => 'originaluser']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
'email' => $user->email,
'username' => 'updateduser',
'full_name' => 'Updated Name',
'phone_number' => '08987654321',
'gender' => 'female',
'birth_date' => '1995-03-20',
'address' => 'Jl. Thamrin No. 2',
]);
$response
@ -28,58 +130,587 @@
$user->refresh();
expect($user->name)->toBe('Test User');
expect($user->email)->toBe('test@example.com');
expect($user->email_verified_at)->toBeNull();
expect($user->username)->toBe('updateduser');
expect($user->userProfile->full_name)->toBe('Updated Name');
expect($user->userProfile->phone_number)->toBe('08987654321');
expect($user->userProfile->gender->value)->toBe('female');
expect($user->userProfile->birth_date->format('Y-m-d'))->toBe('1995-03-20');
expect($user->userProfile->address)->toBe('Jl. Thamrin No. 2');
});
test('email verification status is unchanged when the email address is unchanged', function () {
$user = User::factory()->create();
test('profile can be updated with only required fields', function () {
$user = User::factory()->create(['username' => 'minimaluser']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => $user->email,
'username' => 'minimaluser',
'full_name' => 'Minimal Update',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('profile.edit'));
expect($user->refresh()->email_verified_at)->not->toBeNull();
expect($user->refresh()->userProfile->full_name)->toBe('Minimal Update');
});
test('user can delete their account', function () {
$user = User::factory()->create();
test('profile update creates user profile if not exists', function () {
$user = User::factory()->create(['username' => 'newprofile']);
expect($user->userProfile)->toBeNull();
$this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'newprofile',
'full_name' => 'New Profile',
'phone_number' => '08111111111',
'gender' => 'male',
'birth_date' => '2000-01-01',
'address' => 'New Address',
]);
$user->refresh();
expect($user->userProfile)->not->toBeNull();
expect($user->userProfile->full_name)->toBe('New Profile');
expect($user->userProfile->phone_number)->toBe('08111111111');
});
test('profile update updates existing user profile', function () {
$user = User::factory()->create(['username' => 'existingprofile']);
$user->userProfile()->create([
'full_name' => 'Old Name',
'phone_number' => '08000000000',
'gender' => 'male',
'address' => 'Old Address',
]);
$this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'existingprofile',
'full_name' => 'New Name',
'phone_number' => '08999999999',
'gender' => 'female',
'address' => 'New Address',
]);
$user->refresh();
expect($user->userProfile->full_name)->toBe('New Name');
expect($user->userProfile->phone_number)->toBe('08999999999');
expect($user->userProfile->gender->value)->toBe('female');
expect($user->userProfile->address)->toBe('New Address');
$this->assertDatabaseCount('user_profiles', 1);
});
test('profile update with null optional fields saves nulls', function () {
$user = User::factory()->create(['username' => 'nulltest']);
$user->userProfile()->create([
'full_name' => 'Has Data',
'phone_number' => '08123456789',
'gender' => 'male',
'birth_date' => '1990-01-01',
'address' => 'Some Address',
]);
$this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'nulltest',
'full_name' => 'Still Has Name',
'phone_number' => null,
'gender' => null,
'birth_date' => null,
'address' => null,
]);
$user->refresh();
expect($user->userProfile->full_name)->toBe('Still Has Name');
expect($user->userProfile->phone_number)->toBeNull();
expect($user->userProfile->gender)->toBeNull();
expect($user->userProfile->birth_date)->toBeNull();
expect($user->userProfile->address)->toBeNull();
});
/*
|--------------------------------------------------------------------------
| PROFILE UPDATE - VALIDATION: EMAIL
|--------------------------------------------------------------------------
*/
test('email is required', function () {
$user = User::factory()->create(['username' => 'emailreq']);
$response = $this
->actingAs($user)
->delete(route('profile.destroy'), [
'password' => 'password',
->patch(route('profile.update'), [
'username' => 'emailreq',
'full_name' => 'Test',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('home'));
$this->assertGuest();
expect($user->fresh())->toBeNull();
$response->assertSessionHasErrors('email');
});
test('correct password must be provided to delete account', function () {
$user = User::factory()->create();
test('email must be valid format', function () {
$user = User::factory()->create(['username' => 'emailfmt']);
$response = $this
->actingAs($user)
->from(route('profile.edit'))
->delete(route('profile.destroy'), [
'password' => 'wrong-password',
->patch(route('profile.update'), [
'email' => 'not-an-email',
'username' => 'emailfmt',
'full_name' => 'Test',
]);
$response
->assertSessionHasErrors('password')
->assertRedirect(route('profile.edit'));
expect($user->fresh())->not->toBeNull();
$response->assertSessionHasErrors('email');
});
test('email must not exceed 255 characters', function () {
$user = User::factory()->create(['username' => 'emailmax']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => str_repeat('a', 246) . '@example.com',
'username' => 'emailmax',
'full_name' => 'Test',
]);
$response->assertSessionHasErrors('email');
});
test('email must be unique excluding self', function () {
$user = User::factory()->create(['email' => 'user1@example.com', 'username' => 'uniqueemail1']);
User::factory()->create(['email' => 'user2@example.com', 'username' => 'uniqueemail2']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => 'user2@example.com',
'username' => 'uniqueemail1',
'full_name' => 'Test',
]);
$response->assertSessionHasErrors('email');
});
test('user can keep their own email on update', function () {
$user = User::factory()->create(['email' => 'keep@example.com', 'username' => 'keepemail']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => 'keep@example.com',
'username' => 'keepemail',
'full_name' => 'Test',
]);
$response->assertSessionHasNoErrors();
});
/*
|--------------------------------------------------------------------------
| PROFILE UPDATE - VALIDATION: USERNAME
|--------------------------------------------------------------------------
*/
test('username is required', function () {
$user = User::factory()->create(['username' => 'usernamereq']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'full_name' => 'Test',
]);
$response->assertSessionHasErrors('username');
});
test('username must not exceed 20 characters', function () {
$user = User::factory()->create(['username' => 'usernamemax']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => str_repeat('a', 21),
'full_name' => 'Test',
]);
$response->assertSessionHasErrors('username');
});
test('username must be alpha_dash', function () {
$user = User::factory()->create(['username' => 'alphauser']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'has spaces!',
'full_name' => 'Test',
]);
$response->assertSessionHasErrors('username');
});
test('username allows dashes and underscores', function () {
$user = User::factory()->create(['username' => 'dashuser']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'user-name_123',
'full_name' => 'Test',
]);
$response->assertSessionHasNoErrors();
});
test('username must be unique excluding self', function () {
$user = User::factory()->create(['username' => 'uniqueuser1']);
User::factory()->create(['username' => 'uniqueuser2']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'uniqueuser2',
'full_name' => 'Test',
]);
$response->assertSessionHasErrors('username');
});
test('user can keep their own username on update', function () {
$user = User::factory()->create(['username' => 'keepuser']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'keepuser',
'full_name' => 'Test',
]);
$response->assertSessionHasNoErrors();
});
/*
|--------------------------------------------------------------------------
| PROFILE UPDATE - VALIDATION: FULL NAME
|--------------------------------------------------------------------------
*/
test('full_name is required', function () {
$user = User::factory()->create(['username' => 'fullnamereq']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'fullnamereq',
]);
$response->assertSessionHasErrors('full_name');
});
test('full_name must not exceed 200 characters', function () {
$user = User::factory()->create(['username' => 'fullnamemax']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'fullnamemax',
'full_name' => str_repeat('a', 201),
]);
$response->assertSessionHasErrors('full_name');
});
/*
|--------------------------------------------------------------------------
| PROFILE UPDATE - VALIDATION: OPTIONAL FIELDS
|--------------------------------------------------------------------------
*/
test('phone_number is optional', function () {
$user = User::factory()->create(['username' => 'phonetest']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'phonetest',
'full_name' => 'Test',
'phone_number' => null,
]);
$response->assertSessionHasNoErrors();
});
test('phone_number must not exceed 20 characters', function () {
$user = User::factory()->create(['username' => 'phonemax']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'phonemax',
'full_name' => 'Test',
'phone_number' => str_repeat('1', 21),
]);
$response->assertSessionHasErrors('phone_number');
});
test('gender must be valid enum value', function () {
$user = User::factory()->create(['username' => 'genderbad']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'genderbad',
'full_name' => 'Test',
'gender' => 'other',
]);
$response->assertSessionHasErrors('gender');
});
test('gender accepts male', function () {
$user = User::factory()->create(['username' => 'gendermale']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'gendermale',
'full_name' => 'Test',
'gender' => 'male',
]);
$response->assertSessionHasNoErrors();
});
test('gender accepts female', function () {
$user = User::factory()->create(['username' => 'genderfemale']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'genderfemale',
'full_name' => 'Test',
'gender' => 'female',
]);
$response->assertSessionHasNoErrors();
});
test('birth_date is optional', function () {
$user = User::factory()->create(['username' => 'birthnull']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'birthnull',
'full_name' => 'Test',
'birth_date' => null,
]);
$response->assertSessionHasNoErrors();
});
test('birth_date must be valid date', function () {
$user = User::factory()->create(['username' => 'birthbad']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'birthbad',
'full_name' => 'Test',
'birth_date' => 'not-a-date',
]);
$response->assertSessionHasErrors('birth_date');
});
test('address is optional', function () {
$user = User::factory()->create(['username' => 'addrnull']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'addrnull',
'full_name' => 'Test',
'address' => null,
]);
$response->assertSessionHasNoErrors();
});
/*
|--------------------------------------------------------------------------
| PROFILE UPDATE - VALIDATION: EMPTY REQUEST
|--------------------------------------------------------------------------
*/
test('user cannot update profile without submitting any data', function () {
$user = User::factory()->create(['username' => 'emptyreq']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), []);
$response->assertSessionHasErrors(['email', 'username', 'full_name']);
});
/*
|--------------------------------------------------------------------------
| PROFILE UPDATE - TOAST / FLASH
|--------------------------------------------------------------------------
*/
test('profile update flashes success toast via inertia', function () {
$user = User::factory()->create(['username' => 'toastuser']);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'toastuser',
'full_name' => 'Toast Test',
]);
$response->assertRedirect();
});
/*
|--------------------------------------------------------------------------
| PROFILE UPDATE - DATA INTEGRITY
|--------------------------------------------------------------------------
*/
test('profile update preserves other user fields', function () {
$user = User::factory()->create(['username' => 'preservetest']);
$this
->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'preservetest',
'full_name' => 'New Name',
]);
$user->refresh();
expect(Hash::check('password', $user->password))->toBeTrue();
});
test('profile update with realistic indonesian data', function () {
$user = User::factory()->create(['email' => 'budi.santoso@gmail.com', 'username' => 'budisan']);
$this
->actingAs($user)
->patch(route('profile.update'), [
'email' => 'budi.santoso@gmail.com',
'username' => 'budisan',
'full_name' => 'Budi Santoso',
'phone_number' => '08123456789',
'gender' => 'male',
'birth_date' => '1990-05-15',
'address' => 'Jl. Sudirman No. 123, Jakarta Selatan',
]);
$user->refresh();
expect($user->email)->toBe('budi.santoso@gmail.com');
expect($user->username)->toBe('budisan');
expect($user->userProfile->full_name)->toBe('Budi Santoso');
expect($user->userProfile->phone_number)->toBe('08123456789');
expect($user->userProfile->gender->value)->toBe('male');
expect($user->userProfile->birth_date->format('Y-m-d'))->toBe('1990-05-15');
expect($user->userProfile->address)->toBe('Jl. Sudirman No. 123, Jakarta Selatan');
});
/*
|--------------------------------------------------------------------------
| ROUTE TESTS
|--------------------------------------------------------------------------
*/
test('settings redirect goes to profile edit', function () {
$user = User::factory()->create(['username' => 'redirtest']);
$response = $this
->actingAs($user)
->get('/settings');
$response->assertRedirect('/settings/profile');
});
/*
|--------------------------------------------------------------------------
| REALISTIC SCENARIOS
|--------------------------------------------------------------------------
*/
test('user updates profile then views it again', function () {
$user = User::factory()->create(['username' => 'scenariouser']);
$this->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'scenariouser',
'full_name' => 'Scenario User',
'phone_number' => '08123456789',
'gender' => 'female',
'birth_date' => '1995-08-20',
'address' => 'Jl. Gatot Subroto No. 45',
]);
$this->actingAs($user->fresh())
->get(route('profile.edit'))
->assertInertia(fn (Assert $page) => $page
->where('user.userProfile.full_name', 'Scenario User')
->where('user.userProfile.phone_number', '08123456789')
->where('user.userProfile.gender', 'female')
->where('user.userProfile.birth_date', '1995-08-20')
->where('user.userProfile.address', 'Jl. Gatot Subroto No. 45')
);
});
test('user updates profile multiple times keeps only latest data', function () {
$user = User::factory()->create(['username' => 'multitimes']);
$this->actingAs($user)
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'multitimes',
'full_name' => 'First Update',
'phone_number' => '08111111111',
]);
$this->actingAs($user->fresh())
->patch(route('profile.update'), [
'email' => $user->email,
'username' => 'multitimes',
'full_name' => 'Second Update',
'phone_number' => '08222222222',
]);
$user->refresh();
expect($user->userProfile->full_name)->toBe('Second Update');
expect($user->userProfile->phone_number)->toBe('08222222222');
$this->assertDatabaseCount('user_profiles', 1);
});

View File

@ -1,72 +1,88 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
uses(RefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| AUTHENTICATION
|--------------------------------------------------------------------------
*/
test('guests are redirected to the login page for security page', function () {
$response = $this->get(route('security.edit'));
$response->assertRedirect(route('login'));
});
test('guests are redirected to the login page for password update', function () {
$response = $this->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response->assertRedirect(route('login'));
});
/*
|--------------------------------------------------------------------------
| SECURITY EDIT PAGE
|--------------------------------------------------------------------------
*/
test('security page is displayed', function () {
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
Features::passkeys([
'confirmPassword' => true,
]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertInertia(fn (Assert $page) => $page
->component('settings/security')
->where('canManagePasskeys', true)
->where('passkeys', [])
->where('canManageTwoFactor', true)
->where('twoFactorEnabled', false),
);
});
test('security page requires password confirmation when enabled', function () {
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
$user = User::factory()->create();
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$response = $this->actingAs($user)
$response = $this
->actingAs($user)
->get(route('security.edit'));
$response->assertRedirect(route('password.confirm'));
$response->assertOk();
});
test('security page renders without two factor when feature is disabled', function () {
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
config(['fortify.features' => []]);
test('security page renders correct inertia component', function () {
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/security')
->where('canManagePasskeys', false)
->where('passkeys', [])
->where('canManageTwoFactor', false)
->missing('twoFactorEnabled')
->missing('requiresConfirmation'),
);
});
test('security page passes password rules', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('security.edit'))
->assertInertia(fn (Assert $page) => $page
->has('passwordRules')
);
});
test('security page does not pass two factor or passkey data', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('security.edit'))
->assertInertia(fn (Assert $page) => $page
->missing('canManageTwoFactor')
->missing('canManagePasskeys')
->missing('passkeys')
->missing('twoFactorEnabled')
->missing('requiresConfirmation')
);
});
/*
|--------------------------------------------------------------------------
| PASSWORD UPDATE - SUCCESS
|--------------------------------------------------------------------------
*/
test('password can be updated', function () {
$user = User::factory()->create();
@ -86,7 +102,28 @@
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
test('correct password must be provided to update password', function () {
test('password update redirects back to security page', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'another-new-password',
'password_confirmation' => 'another-new-password',
]);
$response->assertRedirect();
});
/*
|--------------------------------------------------------------------------
| PASSWORD UPDATE - VALIDATION: CURRENT PASSWORD
|--------------------------------------------------------------------------
*/
test('correct current password must be provided to update password', function () {
$user = User::factory()->create();
$response = $this
@ -101,4 +138,193 @@
$response
->assertSessionHasErrors('current_password')
->assertRedirect(route('security.edit'));
expect(Hash::check('new-password', $user->refresh()->password))->toBeFalse();
});
test('current_password is required', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response->assertSessionHasErrors('current_password');
});
/*
|--------------------------------------------------------------------------
| PASSWORD UPDATE - VALIDATION: NEW PASSWORD
|--------------------------------------------------------------------------
*/
test('password is required', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password_confirmation' => 'new-password',
]);
$response->assertSessionHasErrors('password');
});
test('password must be confirmed', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
]);
$response->assertSessionHasErrors('password');
});
test('password and confirmation must match', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'different-password',
]);
$response->assertSessionHasErrors('password');
});
/*
|--------------------------------------------------------------------------
| PASSWORD UPDATE - VALIDATION: EMPTY REQUEST
|--------------------------------------------------------------------------
*/
test('user cannot update password without submitting any data', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), []);
$response->assertSessionHasErrors(['current_password', 'password']);
});
/*
|--------------------------------------------------------------------------
| PASSWORD UPDATE - TOAST / FLASH
|--------------------------------------------------------------------------
*/
test('password update flashes success toast via inertia', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response->assertRedirect();
});
/*
|--------------------------------------------------------------------------
| PASSWORD UPDATE - DATA INTEGRITY
|--------------------------------------------------------------------------
*/
test('password update does not change other user fields', function () {
$user = User::factory()->create([
'email' => 'test@example.com',
'username' => 'testuser',
]);
$this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$user->refresh();
expect($user->email)->toBe('test@example.com');
expect($user->username)->toBe('testuser');
});
/*
|--------------------------------------------------------------------------
| AUTHORIZATION - GUEST CANNOT PERFORM ACTIONS
|--------------------------------------------------------------------------
*/
test('guest cannot access security page', function () {
$response = $this->get(route('security.edit'));
$response->assertRedirect(route('login'));
});
test('guest cannot update password', function () {
$response = $this->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response->assertRedirect(route('login'));
});
/*
|--------------------------------------------------------------------------
| REALISTIC USER SCENARIOS
|--------------------------------------------------------------------------
*/
test('user changes password then can login with new password', function () {
$user = User::factory()->create(['password' => 'old-password']);
$this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'old-password',
'password' => 'brand-new-password',
'password_confirmation' => 'brand-new-password',
]);
$user->refresh();
expect(Hash::check('brand-new-password', $user->password))->toBeTrue();
expect(Hash::check('old-password', $user->password))->toBeFalse();
});
test('user tries to set same password as current', function () {
$user = User::factory()->create(['password' => 'same-password']);
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'same-password',
'password' => 'same-password',
'password_confirmation' => 'same-password',
]);
$response->assertSessionHasNoErrors();
expect(Hash::check('same-password', $user->refresh()->password))->toBeTrue();
});