feat: localize UI to Indonesian, update profile form with extended fields, and disable 2FA password confirmation requirement

This commit is contained in:
Yoga Pangestu 2026-04-24 19:54:59 +07:00
parent 9e973948d2
commit 22f214dc64
12 changed files with 435 additions and 329 deletions

View File

@ -15,19 +15,32 @@ trait ProfileValidationRules
protected function profileRules(?int $userId = null): array protected function profileRules(?int $userId = null): array
{ {
return [ return [
'name' => $this->nameRules(), 'username' => $this->usernameRules($userId),
'email' => $this->emailRules($userId), 'email' => $this->emailRules($userId),
'nik' => ['required', 'string', 'size:16'],
'full_name' => ['required', 'string', 'max:100'],
'phone_number' => ['required', 'string', 'max:20'],
'address' => ['required', 'string'],
'birth_place' => ['required', 'string', 'max:100'],
'birth_date' => ['required', 'date'],
]; ];
} }
/** /**
* Get the validation rules used to validate user names. * Get the validation rules used to validate usernames.
* *
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string> * @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/ */
protected function nameRules(): array protected function usernameRules(?int $userId = null): array
{ {
return ['required', 'string', 'max:255']; return [
'required',
'string',
'max:20',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
} }
/** /**

View File

@ -9,6 +9,7 @@
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -30,17 +31,32 @@ public function edit(Request $request): Response
*/ */
public function update(ProfileUpdateRequest $request): RedirectResponse public function update(ProfileUpdateRequest $request): RedirectResponse
{ {
$request->user()->fill($request->validated()); $user = $request->user();
$validated = $request->validated();
if ($request->user()->isDirty('email')) { DB::transaction(function () use ($user, $validated) {
$request->user()->email_verified_at = null; $user->fill([
} 'username' => $validated['username'],
'email' => $validated['email'],
]);
$request->user()->save(); if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]); $user->save();
return to_route('profile.edit'); $user->profile()->update([
'nik' => $validated['nik'],
'full_name' => $validated['full_name'],
'phone_number' => $validated['phone_number'],
'address' => $validated['address'],
'birth_place' => $validated['birth_place'],
'birth_date' => $validated['birth_date'],
]);
});
return redirect()->route('profile.edit')->with('success', __('Profile updated.'));
} }
/** /**

View File

@ -31,7 +31,7 @@ public function user(): BelongsTo
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
{ {
return LogOptions::defaults() return LogOptions::defaults()
->logOnly(['full_name', 'phone', 'address', 'base_salary']) ->logOnly(['full_name', 'phone_number', 'address', 'base_salary'])
->logOnlyDirty() ->logOnlyDirty()
->useLogName('Profil Pegawai'); ->useLogName('Profil Pegawai');
} }
@ -48,7 +48,7 @@ public function tapActivity(Activity $activity, string $eventName)
if (isset($activity->properties['attributes'])) { if (isset($activity->properties['attributes'])) {
$attributeMap = [ $attributeMap = [
'full_name' => 'Nama Lengkap', 'full_name' => 'Nama Lengkap',
'phone' => 'No. Telepon', 'phone_number' => 'No. Telepon',
'address' => 'Alamat', 'address' => 'Alamat',
'base_salary' => 'Gaji Pokok', 'base_salary' => 'Gaji Pokok',
]; ];

View File

@ -149,7 +149,7 @@
Features::emailVerification(), Features::emailVerification(),
Features::twoFactorAuthentication([ Features::twoFactorAuthentication([
'confirm' => true, 'confirm' => true,
'confirmPassword' => true, 'confirmPassword' => false,
// 'window' => 0 // 'window' => 0
]), ]),
], ],

View File

@ -58,5 +58,7 @@
"Verify Email Address": "Verifikasi Alamat Surel", "Verify Email Address": "Verifikasi Alamat Surel",
"Verify your email address": "Verifikasi alamat email Anda", "Verify your email address": "Verifikasi alamat email Anda",
"Whoops!": "Aduh!", "Whoops!": "Aduh!",
"You are receiving this email because we received a password reset request for your account.": "Anda menerima surel ini karena kami menerima permintaan pengaturan ulang kata sandi untuk akun anda." "You are receiving this email because we received a password reset request for your account.": "Anda menerima surel ini karena kami menerima permintaan pengaturan ulang kata sandi untuk akun anda.",
"Profile updated.": "Profil berhasil diperbarui.",
"Password updated.": "Kata sandi berhasil diperbarui."
} }

View File

@ -12,9 +12,9 @@ export default function AppearanceToggleTab({
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [ const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
{ value: 'light', icon: Sun, label: 'Light' }, { value: 'light', icon: Sun, label: 'Terang' },
{ value: 'dark', icon: Moon, label: 'Dark' }, { value: 'dark', icon: Moon, label: 'Gelap' },
{ value: 'system', icon: Monitor, label: 'System' }, { value: 'system', icon: Monitor, label: 'Sistem' },
]; ];
return ( return (

View File

@ -23,14 +23,14 @@ export default function DeleteUser() {
<div className="space-y-6"> <div className="space-y-6">
<Heading <Heading
variant="small" variant="small"
title="Delete account" title="Hapus akun"
description="Delete your account and all of its resources" description="Hapus akun Anda dan semua sumber daya di dalamnya"
/> />
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"> <div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
<div className="relative space-y-0.5 text-red-600 dark:text-red-100"> <div className="relative space-y-0.5 text-red-600 dark:text-red-100">
<p className="font-medium">Warning</p> <p className="font-medium">Peringatan</p>
<p className="text-sm"> <p className="text-sm">
Please proceed with caution, this cannot be undone. Harap berhati-hati, tindakan ini tidak dapat dibatalkan.
</p> </p>
</div> </div>
@ -40,18 +40,17 @@ export default function DeleteUser() {
variant="destructive" variant="destructive"
data-test="delete-user-button" data-test="delete-user-button"
> >
Delete account Hapus Akun
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogTitle> <DialogTitle>
Are you sure you want to delete your account? Apakah Anda yakin ingin menghapus akun Anda?
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Once your account is deleted, all of its resources Setelah akun Anda dihapus, semua sumber daya dan datanya
and data will also be permanently deleted. Please akan dihapus secara permanen. Silakan masukkan kata sandi Anda
enter your password to confirm you would like to untuk mengonfirmasi bahwa Anda ingin menghapus akun Anda secara permanen.
permanently delete your account.
</DialogDescription> </DialogDescription>
<Form <Form
@ -70,14 +69,14 @@ export default function DeleteUser() {
htmlFor="password" htmlFor="password"
className="sr-only" className="sr-only"
> >
Password Kata Sandi
</Label> </Label>
<PasswordInput <PasswordInput
id="password" id="password"
name="password" name="password"
ref={passwordInput} ref={passwordInput}
placeholder="Password" placeholder="Kata Sandi"
autoComplete="current-password" autoComplete="current-password"
/> />
@ -92,7 +91,7 @@ export default function DeleteUser() {
resetAndClearErrors() resetAndClearErrors()
} }
> >
Cancel Batal
</Button> </Button>
</DialogClose> </DialogClose>
@ -105,7 +104,7 @@ export default function DeleteUser() {
type="submit" type="submit"
data-test="confirm-delete-user-button" data-test="confirm-delete-user-button"
> >
Delete account Hapus Akun
</button> </button>
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -41,7 +41,7 @@ export function UserMenuContent({ user }: Props) {
onClick={cleanup} onClick={cleanup}
> >
<Settings className="mr-2" /> <Settings className="mr-2" />
Settings Pengaturan
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
@ -55,7 +55,7 @@ export function UserMenuContent({ user }: Props) {
data-test="logout-button" data-test="logout-button"
> >
<LogOut className="mr-2" /> <LogOut className="mr-2" />
Log out Keluar
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</> </>

View File

@ -12,17 +12,17 @@ import type { NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [ const sidebarNavItems: NavItem[] = [
{ {
title: 'Profile', title: 'Profil',
href: edit(), href: edit(),
icon: null, icon: null,
}, },
{ {
title: 'Security', title: 'Keamanan',
href: editSecurity(), href: editSecurity(),
icon: null, icon: null,
}, },
{ {
title: 'Appearance', title: 'Tampilan',
href: editAppearance(), href: editAppearance(),
icon: null, icon: null,
}, },
@ -34,15 +34,14 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
return ( return (
<div className="px-4 py-6"> <div className="px-4 py-6">
<Heading <Heading
title="Settings" title="Pengaturan"
description="Manage your profile and account settings"
/> />
<div className="flex flex-col lg:flex-row lg:space-x-12"> <div className="flex flex-col lg:flex-row lg:space-x-12">
<aside className="w-full max-w-xl lg:w-48"> <aside className="w-full max-w-xl lg:w-48">
<nav <nav
className="flex flex-col space-y-1 space-x-0" className="flex flex-col space-y-1 space-x-0"
aria-label="Settings" aria-label="Pengaturan"
> >
{sidebarNavItems.map((item, index) => ( {sidebarNavItems.map((item, index) => (
<Button <Button
@ -67,8 +66,8 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
<Separator className="my-6 lg:hidden" /> <Separator className="my-6 lg:hidden" />
<div className="flex-1 md:max-w-2xl"> <div className="flex-1">
<section className="max-w-xl space-y-12"> <section className="space-y-12">
{children} {children}
</section> </section>
</div> </div>

View File

@ -6,15 +6,15 @@ import { edit as editAppearance } from '@/routes/appearance';
export default function Appearance() { export default function Appearance() {
return ( return (
<> <>
<Head title="Appearance settings" /> <Head title="Pengaturan Tampilan" />
<h1 className="sr-only">Appearance settings</h1> <h1 className="sr-only">Pengaturan Tampilan</h1>
<div className="space-y-6"> <div className="space-y-6">
<Heading <Heading
variant="small" variant="small"
title="Appearance settings" title="Tampilan"
description="Update your account's appearance settings" description="Sesuaikan tampilan aplikasi sesuai keinginan Anda"
/> />
<AppearanceTabs /> <AppearanceTabs />
</div> </div>
@ -25,7 +25,7 @@ export default function Appearance() {
Appearance.layout = { Appearance.layout = {
breadcrumbs: [ breadcrumbs: [
{ {
title: 'Appearance settings', title: 'Pengaturan Tampilan',
href: editAppearance(), href: editAppearance(),
}, },
], ],

View File

@ -1,133 +1,222 @@
import { Form, Head, Link, usePage } from '@inertiajs/react';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/delete-user';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Field, FieldError } from "@/components/ui/field";
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Textarea } from '@/components/ui/textarea';
import { edit } from '@/routes/profile'; import { edit } from '@/routes/profile';
import { send } from '@/routes/verification'; import { Head, useForm, usePage } from '@inertiajs/react';
import { Loader, Save } from 'lucide-react';
import React from 'react';
import { toast } from 'sonner';
export default function Profile({ export default function Profile({
mustVerifyEmail,
status, status,
}: { }: {
mustVerifyEmail: boolean;
status?: string; status?: string;
}) { }) {
const { auth } = usePage().props; const { auth } = usePage().props;
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
const { data, setData, patch, processing, errors } = useForm({
nik: auth.user.profile?.nik || '',
full_name: auth.user.profile?.full_name || '',
email: auth.user.email || '',
username: auth.user.username || '',
phone_number: auth.user.profile?.phone_number || '',
birth_place: auth.user.profile?.birth_place || '',
birth_date: auth.user.profile?.birth_date || '',
address: auth.user.profile?.address || '',
});
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
patch(ProfileController.update().url, {
preserveScroll: true,
onSuccess: (response: any) => {
toast.success(response.props.flash.success || 'Profil berhasil diperbarui.');
},
});
};
return ( return (
<> <div className="flex flex-col gap-6">
<Head title="Profile settings" /> <Head title="Profil" />
<h1 className="sr-only">Profile settings</h1> <h1 className="sr-only">Profil</h1>
<div className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
<Heading <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
variant="small" <div className="lg:col-span-2 space-y-6">
title="Profile information" <Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
description="Update your name and email address" <CardHeader className="border-b">
/> <CardTitle>Informasi Personal</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Field>
<Label htmlFor="nik" required>NIK</Label>
<Input
id="nik"
value={data.nik}
onChange={(e) => setData('nik', e.target.value)}
placeholder="Contoh: 3213051307900001"
maxLength={16}
autoComplete="off"
/>
<FieldError error={errors.nik} label="NIK" className="text-xs" />
</Field>
<Form <Field>
{...ProfileController.update.form()} <Label htmlFor="full_name" required>Nama Lengkap</Label>
options={{ <Input
preserveScroll: true, id="full_name"
}} value={data.full_name}
className="space-y-6" onChange={(e) => setData('full_name', e.target.value)}
> placeholder="Nama Lengkap"
{({ processing, errors }) => ( autoComplete="off"
<> />
<div className="grid gap-2"> <FieldError error={errors.full_name} label="Nama Lengkap" className="text-xs" />
<Label htmlFor="name" required>Name</Label> </Field>
<Input <Field>
id="name" <Label htmlFor="phone_number" required>Nomor Telepon</Label>
className="mt-1 block w-full" <Input
defaultValue={auth.user.name} id="phone_number"
name="name" value={data.phone_number}
required onChange={(e) => setData('phone_number', e.target.value)}
autoComplete="name" placeholder="Nomor Telepon"
placeholder="Full name" autoComplete="off"
/> />
<FieldError error={errors.phone_number} label="Nomor Telepon" className="text-xs" />
</Field>
<InputError <Field>
className="mt-2" <Label htmlFor="birth_place" required>Tempat Lahir</Label>
message={errors.name} <Input
label="Name" id="birth_place"
/> value={data.birth_place}
</div> onChange={(e) => setData('birth_place', e.target.value)}
placeholder="Tempat Lahir"
autoComplete="off"
/>
<FieldError error={errors.birth_place} label="Tempat Lahir" className="text-xs" />
</Field>
<div className="grid gap-2"> <Field>
<Label htmlFor="email" required>Email address</Label> <Label htmlFor="birth_date" required>Tanggal Lahir</Label>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="birth_date"
className="w-full justify-start font-normal"
>
{data.birth_date ? (
new Intl.DateTimeFormat("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(data.birth_date))
) : (
<span className="text-muted-foreground">Pilih Tanggal</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={data.birth_date ? new Date(data.birth_date) : undefined}
defaultMonth={data.birth_date ? new Date(data.birth_date) : new Date(2000, 0, 1)}
captionLayout="dropdown"
onSelect={(selectedDate: Date | undefined) => {
if (selectedDate) {
setData('birth_date', selectedDate.getFullYear() + "-" + String(selectedDate.getMonth() + 1).padStart(2, '0') + "-" + String(selectedDate.getDate()).padStart(2, '0'));
} else {
setData('birth_date', '');
}
<Input setIsCalendarOpen(false);
id="email" }}
type="email" />
className="mt-1 block w-full" </PopoverContent>
defaultValue={auth.user.email} </Popover>
name="email" <FieldError error={errors.birth_date} label="Tanggal Lahir" className="text-xs" />
required </Field>
autoComplete="username" </div>
placeholder="Email address"
/>
<InputError <Field>
className="mt-2" <Label htmlFor="address" required>Alamat</Label>
message={errors.email} <Textarea
label="Email address" id="address"
/> value={data.address}
</div> onChange={(e) => setData('address', e.target.value)}
placeholder="Alamat Lengkap"
rows={3}
/>
<FieldError error={errors.address} label="Alamat" className="text-xs" />
</Field>
</CardContent>
</Card>
</div>
{mustVerifyEmail && <div className="space-y-6">
auth.user.email_verified_at === null && ( <Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<div> <CardHeader className="border-b">
<p className="-mt-4 text-sm text-muted-foreground"> <CardTitle>Kredensial Akun</CardTitle>
Your email address is unverified.{' '} </CardHeader>
<Link <CardContent className="space-y-6">
href={send()} <Field>
as="button" <Label htmlFor="email" required>Alamat Email</Label>
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500" <Input
> id="email"
Click here to resend the type="email"
verification email. value={data.email}
</Link> onChange={(e) => setData('email', e.target.value)}
</p> autoComplete="off"
placeholder="Alamat Email"
/>
<FieldError error={errors.email} label="Alamat Email" className="text-xs" />
</Field>
{status === <Field>
'verification-link-sent' && ( <Label htmlFor="username" required>Nama Pengguna</Label>
<div className="mt-2 text-sm font-medium text-green-600"> <Input
A new verification link has been id="username"
sent to your email address. value={data.username}
</div> onChange={(e) => setData('username', e.target.value)}
)} placeholder="Username"
</div> autoComplete="off"
)} />
<FieldError error={errors.username} label="Nama Pengguna" className="text-xs" />
</Field>
</CardContent>
</Card>
<div className="flex items-center gap-4"> <div className="flex flex-col gap-4 p-4 rounded-xl bg-primary/5 border border-primary/10 shadow-sm">
<Button <Button type="submit" className="w-full" disabled={processing}>
disabled={processing} {processing ? <Loader className="size-4 animate-spin" /> : <Save className="size-4" />}
data-test="update-profile-button" {processing ? 'Menyimpan...' : 'Simpan'}
> </Button>
Save </div>
</Button> </div>
</div> </div>
</> </form>
)} </div>
</Form>
</div>
<DeleteUser />
</>
); );
} }
Profile.layout = { Profile.layout = {
breadcrumbs: [ breadcrumbs: [
{ {
title: 'Profile settings', title: 'Pengaturan Profil',
href: edit(), href: edit(),
}, },
], ],

View File

@ -1,17 +1,18 @@
import { Form, Head } from '@inertiajs/react';
import { ShieldCheck } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController'; import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input'; import PasswordInput from '@/components/password-input';
import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes'; import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes';
import TwoFactorSetupModal from '@/components/two-factor-setup-modal'; import TwoFactorSetupModal from '@/components/two-factor-setup-modal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Field, FieldError } from "@/components/ui/field";
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
import { edit } from '@/routes/security'; import { edit } from '@/routes/security';
import { disable, enable } from '@/routes/two-factor'; import { disable, enable } from '@/routes/two-factor';
import { Head, useForm } from '@inertiajs/react';
import { Loader, Save, ShieldCheck } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
type Props = { type Props = {
canManageTwoFactor?: boolean; canManageTwoFactor?: boolean;
@ -36,11 +37,20 @@ export default function Security({
fetchSetupData, fetchSetupData,
recoveryCodesList, recoveryCodesList,
fetchRecoveryCodes, fetchRecoveryCodes,
errors, errors: tfaErrors,
} = useTwoFactorAuth(); } = useTwoFactorAuth();
const [showSetupModal, setShowSetupModal] = useState<boolean>(false); const [showSetupModal, setShowSetupModal] = useState<boolean>(false);
const prevTwoFactorEnabled = useRef(twoFactorEnabled); const prevTwoFactorEnabled = useRef(twoFactorEnabled);
const { data, setData, put, processing, errors, reset } = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const { post: postEnable, processing: processingEnable } = useForm({});
const { post: postDisable, processing: processingDisable } = useForm({});
useEffect(() => { useEffect(() => {
if (prevTwoFactorEnabled.current && !twoFactorEnabled) { if (prevTwoFactorEnabled.current && !twoFactorEnabled) {
clearTwoFactorAuthData(); clearTwoFactorAuthData();
@ -49,201 +59,179 @@ export default function Security({
prevTwoFactorEnabled.current = twoFactorEnabled; prevTwoFactorEnabled.current = twoFactorEnabled;
}, [twoFactorEnabled, clearTwoFactorAuthData]); }, [twoFactorEnabled, clearTwoFactorAuthData]);
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
put(SecurityController.update().url, {
preserveScroll: true,
onSuccess: () => {
toast.success('Kata sandi berhasil diperbarui.');
reset();
},
onError: (errors) => {
if (errors.password) {
passwordInput.current?.focus();
}
if (errors.current_password) {
currentPasswordInput.current?.focus();
}
},
});
};
const handleEnable2FA = (e: React.FormEvent) => {
e.preventDefault();
postEnable(enable().url, {
onSuccess: () => setShowSetupModal(true),
});
};
const handleDisable2FA = (e: React.FormEvent) => {
e.preventDefault();
postDisable(disable().url);
};
return ( return (
<> <div className="flex flex-col gap-6">
<Head title="Security settings" /> <Head title="Keamanan Akun" />
<h1 className="sr-only">Security settings</h1> <h1 className="sr-only">Keamanan Akun</h1>
<div className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
<Heading <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
variant="small" <div className="lg:col-span-2 space-y-6">
title="Update password" <Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
description="Ensure your account is using a long, random password to stay secure" <CardHeader className="border-b">
/> <CardTitle>Perbarui Kata Sandi</CardTitle>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<Field>
<Label htmlFor="current_password" required>Kata Sandi Saat Ini</Label>
<PasswordInput
id="current_password"
ref={currentPasswordInput}
value={data.current_password}
onChange={(e) => setData('current_password', e.target.value)}
className="w-full"
autoComplete="current-password"
placeholder="Masukkan kata sandi saat ini"
/>
<FieldError error={errors.current_password} label="Kata sandi saat ini" className="text-xs" />
</Field>
<Form <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{...SecurityController.update.form()} <Field>
options={{ <Label htmlFor="password" required>Kata Sandi Baru</Label>
preserveScroll: true, <PasswordInput
}} id="password"
resetOnError={[ ref={passwordInput}
'password', value={data.password}
'password_confirmation', onChange={(e) => setData('password', e.target.value)}
'current_password', placeholder="Masukkan kata sandi baru"
]} autoComplete="new-password"
resetOnSuccess />
onError={(errors) => { <FieldError error={errors.password} label="Kata sandi baru" className="text-xs" />
if (errors.password) { </Field>
passwordInput.current?.focus();
}
if (errors.current_password) { <Field>
currentPasswordInput.current?.focus(); <Label htmlFor="password_confirmation" required>Konfirmasi Kata Sandi Baru</Label>
} <PasswordInput
}} id="password_confirmation"
className="space-y-6" value={data.password_confirmation}
> onChange={(e) => setData('password_confirmation', e.target.value)}
{({ errors, processing }) => ( placeholder="Ulangi kata sandi baru"
<> autoComplete="new-password"
<div className="grid gap-2"> />
<Label htmlFor="current_password" required> <FieldError error={errors.password_confirmation} label="Konfirmasi kata sandi baru" className="text-xs" />
Current password </Field>
</Label> </div>
<PasswordInput <div className="flex justify-end">
id="current_password" <Button type="submit" disabled={processing}>
ref={currentPasswordInput} {processing ? <Loader className="size-4 animate-spin" /> : <Save className="size-4" />}
name="current_password" {processing ? 'Menyimpan...' : 'Simpan'}
className="mt-1 block w-full" </Button>
autoComplete="current-password" </div>
placeholder="Current password" </CardContent>
/> </Card>
<InputError message={errors.current_password} label="Current password" /> {canManageTwoFactor && (
</div> <Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm">
<CardHeader className="border-b">
<CardTitle>Autentikasi Dua Faktor (2FA)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{twoFactorEnabled
? "Anda akan diminta memasukkan PIN acak yang aman saat masuk, yang dapat Anda ambil dari aplikasi pendukung TOTP di ponsel Anda."
: "Saat Anda mengaktifkan autentikasi dua faktor, Anda akan diminta PIN aman saat masuk. PIN ini dapat diambil dari aplikasi pendukung TOTP di ponsel Anda."
}
</p>
<div className="grid gap-2"> {twoFactorEnabled ? (
<Label htmlFor="password" required>New password</Label> <div className="space-y-4">
<Button
type="button"
variant="destructive"
onClick={handleDisable2FA}
disabled={processingDisable}
>
{processingDisable ? <Loader className="size-4 animate-spin" /> : null}
Nonaktifkan 2FA
</Button>
<PasswordInput <TwoFactorRecoveryCodes
id="password" recoveryCodesList={recoveryCodesList}
ref={passwordInput} fetchRecoveryCodes={fetchRecoveryCodes}
name="password" errors={tfaErrors}
className="mt-1 block w-full" />
autoComplete="new-password" </div>
placeholder="New password" ) : (
/> <div>
{hasSetupData ? (
<InputError message={errors.password} label="New password" /> <Button type="button" onClick={() => setShowSetupModal(true)}>
</div> <ShieldCheck className="mr-2 h-4 w-4" />
Lanjutkan Penyiapan
<div className="grid gap-2"> </Button>
<Label htmlFor="password_confirmation" required> ) : (
Confirm password <Button
</Label> type="button"
onClick={handleEnable2FA}
<PasswordInput disabled={processingEnable}
id="password_confirmation" >
name="password_confirmation" {processingEnable ? <Loader className="size-4 animate-spin" /> : null}
className="mt-1 block w-full" Aktifkan 2FA
autoComplete="new-password" </Button>
placeholder="Confirm password" )}
/> </div>
)}
<InputError </CardContent>
message={errors.password_confirmation} </Card>
label="Confirm password" )}
/> </div>
</div> </div>
</form>
<div className="flex items-center gap-4">
<Button
disabled={processing}
data-test="update-password-button"
>
Save password
</Button>
</div>
</>
)}
</Form>
</div>
{canManageTwoFactor && ( {canManageTwoFactor && (
<div className="space-y-6"> <TwoFactorSetupModal
<Heading isOpen={showSetupModal}
variant="small" onClose={() => setShowSetupModal(false)}
title="Two-factor authentication" requiresConfirmation={requiresConfirmation}
description="Manage your two-factor authentication settings" twoFactorEnabled={twoFactorEnabled}
/> qrCodeSvg={qrCodeSvg}
{twoFactorEnabled ? ( manualSetupKey={manualSetupKey}
<div className="flex flex-col items-start justify-start space-y-4"> clearSetupData={clearSetupData}
<p className="text-sm text-muted-foreground"> fetchSetupData={fetchSetupData}
You will be prompted for a secure, random pin errors={tfaErrors}
during login, which you can retrieve from the />
TOTP-supported application on your phone.
</p>
<div className="relative inline">
<Form {...disable.form()}>
{({ processing }) => (
<Button
variant="destructive"
type="submit"
disabled={processing}
>
Disable 2FA
</Button>
)}
</Form>
</div>
<TwoFactorRecoveryCodes
recoveryCodesList={recoveryCodesList}
fetchRecoveryCodes={fetchRecoveryCodes}
errors={errors}
/>
</div>
) : (
<div className="flex flex-col items-start justify-start space-y-4">
<p className="text-sm text-muted-foreground">
When you enable two-factor authentication, you
will be prompted for a secure pin during login.
This pin can be retrieved from a TOTP-supported
application on your phone.
</p>
<div>
{hasSetupData ? (
<Button
onClick={() => setShowSetupModal(true)}
>
<ShieldCheck />
Continue setup
</Button>
) : (
<Form
{...enable.form()}
onSuccess={() =>
setShowSetupModal(true)
}
>
{({ processing }) => (
<Button
type="submit"
disabled={processing}
>
Enable 2FA
</Button>
)}
</Form>
)}
</div>
</div>
)}
<TwoFactorSetupModal
isOpen={showSetupModal}
onClose={() => setShowSetupModal(false)}
requiresConfirmation={requiresConfirmation}
twoFactorEnabled={twoFactorEnabled}
qrCodeSvg={qrCodeSvg}
manualSetupKey={manualSetupKey}
clearSetupData={clearSetupData}
fetchSetupData={fetchSetupData}
errors={errors}
/>
</div>
)} )}
</> </div>
); );
} }
Security.layout = { Security.layout = {
breadcrumbs: [ breadcrumbs: [
{ {
title: 'Security settings', title: 'Keamanan Akun',
href: edit(), href: edit(),
}, },
], ],