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:
parent
6dbe9da581
commit
aecd8eaf1f
15
app/Http/Controllers/Settings/PermissionController.php
Normal file
15
app/Http/Controllers/Settings/PermissionController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
],
|
||||
|
||||
188
resources/js/pages/settings/permissions.tsx
Normal file
188
resources/js/pages/settings/permissions.tsx
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -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(),
|
||||
},
|
||||
],
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
],
|
||||
|
||||
@ -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 () {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user