From aecd8eaf1f83e6b084f8fd25b641d0f22a924f7b Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 30 Jul 2026 22:08:24 +0700 Subject: [PATCH] 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. --- .../Settings/PermissionController.php | 15 + .../Settings/ProfileController.php | 21 +- .../Settings/SecurityController.php | 32 +- .../Settings/ProfileUpdateRequest.php | 31 +- resources/js/components/user-menu-content.tsx | 8 +- resources/js/layouts/settings/layout.tsx | 28 +- resources/js/pages/auth/login.tsx | 4 +- resources/js/pages/settings/appearance.tsx | 16 +- resources/js/pages/settings/permissions.tsx | 188 +++++ resources/js/pages/settings/profile.tsx | 228 +++--- resources/js/pages/settings/security.tsx | 143 ++-- routes/settings.php | 8 +- tests/Feature/Settings/ProfileUpdateTest.php | 691 +++++++++++++++++- tests/Feature/Settings/SecurityTest.php | 322 ++++++-- 14 files changed, 1407 insertions(+), 328 deletions(-) create mode 100644 app/Http/Controllers/Settings/PermissionController.php create mode 100644 resources/js/pages/settings/permissions.tsx diff --git a/app/Http/Controllers/Settings/PermissionController.php b/app/Http/Controllers/Settings/PermissionController.php new file mode 100644 index 0000000..4ad3cfa --- /dev/null +++ b/app/Http/Controllers/Settings/PermissionController.php @@ -0,0 +1,15 @@ +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'); } diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index d2800f8..9c11218 100644 --- a/app/Http/Controllers/Settings/SecurityController.php +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -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(); } diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index e4eb8d8..78e2bca 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -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> + * @return array|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'], + ]; } } diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index adbda71..bed9ee1 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -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 + Pengaturan @@ -55,7 +55,7 @@ export function UserMenuContent({ user }: Props) { data-test="logout-button" > - Log out + Keluar diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index efd7904..db3d07c 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -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 (
@@ -67,11 +73,9 @@ export default function SettingsLayout({ children }: PropsWithChildren) { -
-
- {children} -
-
+
+ {children} +
); diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index 0848e34..38d5e42 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -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() {
diff --git a/resources/js/pages/settings/appearance.tsx b/resources/js/pages/settings/appearance.tsx index ca18d76..4fcf9ed 100644 --- a/resources/js/pages/settings/appearance.tsx +++ b/resources/js/pages/settings/appearance.tsx @@ -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 ( <> - + -

Appearance settings

+

Tampilan

-
- +
@@ -25,7 +19,7 @@ export default function Appearance() { Appearance.layout = { breadcrumbs: [ { - title: 'Appearance settings', + title: 'Tampilan', href: editAppearance(), }, ], diff --git a/resources/js/pages/settings/permissions.tsx b/resources/js/pages/settings/permissions.tsx new file mode 100644 index 0000000..11e1ae5 --- /dev/null +++ b/resources/js/pages/settings/permissions.tsx @@ -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('unknown'); + const [camera, setCamera] = useState('unknown'); + const [location, setLocation] = useState('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 ( + <> + + +

Izin

+ +
+ + +
+
+ Notifikasi + + Izinkan aplikasi mengirimkan notifikasi push ke perangkat Anda. + + {isDenied(notifications) && ( +

+ Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya. +

+ )} +
+
+ +
+
+
+ + + +
+
+ Kamera + + Izinkan aplikasi mengakses kamera perangkat Anda untuk mengambil foto atau video. + + {isDenied(camera) && ( +

+ Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya. +

+ )} +
+
+ +
+
+
+ + + +
+
+ Lokasi + + Izinkan aplikasi mengakses lokasi perangkat Anda untuk menyediakan layanan berbasis lokasi. + + {isDenied(location) && ( +

+ Izin ditolak. Klik ikon gembok di address bar untuk mengaktifkannya. +

+ )} +
+
+ +
+
+
+
+ + ); +} + +Permissions.layout = { + breadcrumbs: [ + { + title: 'Izin', + href: edit(), + }, + ], +}; diff --git a/resources/js/pages/settings/profile.tsx b/resources/js/pages/settings/profile.tsx index 0a242cd..6331b8c 100644 --- a/resources/js/pages/settings/profile.tsx +++ b/resources/js/pages/settings/profile.tsx @@ -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().props; +}; + +export default function Profile({ user }: Props) { + const [birthDate, setBirthDate] = useState( + user.userProfile?.birth_date ? new Date(user.userProfile.birth_date) : undefined + ); return ( <> - - -

Profile settings

- -
- + +
{({ processing, errors }) => ( - <> -
- - - - - -
- -
- - - - - -
- - {mustVerifyEmail && - auth.user.email_verified_at === null && ( -
-

- Your email address is unverified.{' '} - - Click here to re-send the - verification email. - -

- - {status === - 'verification-link-sent' && ( -
- A new verification link has been - sent to your email address. -
- )} +
+ + + Akun + + +
+ + +
- )} +
+ + + +
+
+
+ + + + Profil + + +
+ + + +
+
+ + + +
+
+ + +
+ + +
+
+ + +
+
+ +
+
+ + + +
+
+ +