feat: localize UI to Indonesian, update profile form with extended fields, and disable 2FA password confirmation requirement
This commit is contained in:
parent
9e973948d2
commit
22f214dc64
@ -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),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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',
|
||||||
];
|
];
|
||||||
|
|||||||
@ -149,7 +149,7 @@
|
|||||||
Features::emailVerification(),
|
Features::emailVerification(),
|
||||||
Features::twoFactorAuthentication([
|
Features::twoFactorAuthentication([
|
||||||
'confirm' => true,
|
'confirm' => true,
|
||||||
'confirmPassword' => true,
|
'confirmPassword' => false,
|
||||||
// 'window' => 0
|
// 'window' => 0
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
@ -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 (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user