diff --git a/app/Concerns/ProfileValidationRules.php b/app/Concerns/ProfileValidationRules.php index 33b6909..0ed6a26 100644 --- a/app/Concerns/ProfileValidationRules.php +++ b/app/Concerns/ProfileValidationRules.php @@ -2,6 +2,7 @@ namespace App\Concerns; +use App\Enums\Gender; use App\Models\User; use Illuminate\Validation\Rule; @@ -12,6 +13,12 @@ protected function profileRules(?int $userId = null): array return [ 'username' => $this->usernameRules($userId), 'email' => $this->emailRules($userId), + 'full_name' => ['required', 'string', 'max:150'], + 'phone_number' => ['required', 'string', 'max:20'], + 'address' => ['required', 'string'], + 'gender' => ['required', 'string', Rule::in(array_values(Gender::cases()))], + 'birth_date' => ['required', 'date'], + 'birth_place' => ['required', 'string', 'max:100'], ]; } diff --git a/app/Http/Controllers/Admin/Settings/ProfileController.php b/app/Http/Controllers/Admin/Settings/ProfileController.php new file mode 100644 index 0000000..c57ab8a --- /dev/null +++ b/app/Http/Controllers/Admin/Settings/ProfileController.php @@ -0,0 +1,111 @@ +user(); + + [$role, $roleData] = match (true) { + $user->hasRole('mahasiswa') => $this->studentRoleData($user), + $user->hasRole('dosen') => $this->lecturerRoleData($user), + default => $this->administratorRoleData($user), + }; + + return Inertia::render('admin/settings/profile', [ + 'mustVerifyEmail' => $user instanceof MustVerifyEmail, + 'status' => $request->session()->get('status'), + 'profile' => $user->profile, + 'role' => $role, + 'roleData' => $roleData, + ]); + } + + /** + * Update the user's profile information. + */ + public function update(ProfileUpdateRequest $request): RedirectResponse + { + $user = $request->user(); + + $user->fill($request->safe()->only(['username', 'email'])); + + if ($user->isDirty('email')) { + $user->email_verified_at = null; + } + + $user->save(); + + $user->profile()->updateOrCreate([], $request->safe()->only([ + 'full_name', 'phone_number', 'address', 'gender', 'birth_date', 'birth_place', + ])); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Profil berhasil diperbarui.']); + + return to_route('admin.settings.profile.edit'); + } + + /** + * Delete the user's profile. + */ + public function destroy(ProfileDeleteRequest $request): RedirectResponse + { + $user = $request->user(); + + Auth::logout(); + + $user->delete(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/'); + } + + private function studentRoleData(User $user): array + { + $user->load(['profile', 'student.department', 'student.academicAdvisor.user.profile']); + + return ['mahasiswa', $user->student ? [ + 'student_number' => $user->student->student_number, + 'department' => $user->student->department?->name, + 'enrollment_year' => $user->student->enrollment_year, + 'academic_advisor' => $user->student->academicAdvisor?->user?->profile?->full_name, + ] : null]; + } + + private function lecturerRoleData(User $user): array + { + $user->load(['profile', 'lecturer.department']); + + return ['dosen', $user->lecturer ? [ + 'lecturer_number' => $user->lecturer->lecturer_number, + 'department' => $user->lecturer->department?->name, + ] : null]; + } + + private function administratorRoleData(User $user): array + { + $user->load(['profile', 'roles']); + + return ['admin', [ + 'roles' => $user->roles->pluck('name')->all(), + ]]; + } +} diff --git a/app/Http/Controllers/Admin/Settings/SecurityController.php b/app/Http/Controllers/Admin/Settings/SecurityController.php new file mode 100644 index 0000000..de8acc8 --- /dev/null +++ b/app/Http/Controllers/Admin/Settings/SecurityController.php @@ -0,0 +1,37 @@ + Password::defaults()->toPasswordRulesString(), + ]); + } + + /** + * Update the user's password. + */ + public function update(PasswordUpdateRequest $request): RedirectResponse + { + $request->user()->update([ + 'password' => $request->password, + ]); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kata sandi berhasil diperbarui.']); + + return back(); + } +} diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php deleted file mode 100644 index d19f963..0000000 --- a/app/Http/Controllers/Settings/ProfileController.php +++ /dev/null @@ -1,62 +0,0 @@ - $request->user() instanceof MustVerifyEmail, - 'status' => $request->session()->get('status'), - ]); - } - - /** - * Update the user's profile information. - */ - public function update(ProfileUpdateRequest $request): RedirectResponse - { - $request->user()->fill($request->validated()); - - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; - } - - $request->user()->save(); - - Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]); - - return to_route('profile.edit'); - } - - /** - * Delete the user's profile. - */ - public function destroy(ProfileDeleteRequest $request): RedirectResponse - { - $user = $request->user(); - - Auth::logout(); - - $user->delete(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return redirect('/'); - } -} diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php deleted file mode 100644 index d2800f8..0000000 --- a/app/Http/Controllers/Settings/SecurityController.php +++ /dev/null @@ -1,66 +0,0 @@ - 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); - } - - /** - * Update the user's password. - */ - public function update(PasswordUpdateRequest $request): RedirectResponse - { - $request->user()->update([ - 'password' => $request->password, - ]); - - Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]); - - return back(); - } -} diff --git a/app/Http/Requests/Settings/PasswordUpdateRequest.php b/app/Http/Requests/Admin/Settings/PasswordUpdateRequest.php similarity index 92% rename from app/Http/Requests/Settings/PasswordUpdateRequest.php rename to app/Http/Requests/Admin/Settings/PasswordUpdateRequest.php index 0e5ff2f..3e338d3 100644 --- a/app/Http/Requests/Settings/PasswordUpdateRequest.php +++ b/app/Http/Requests/Admin/Settings/PasswordUpdateRequest.php @@ -1,6 +1,6 @@ |string> - */ - public function rules(): array - { - return []; - } -} diff --git a/app/Models/User.php b/app/Models/User.php index 111973a..e6a8060 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Passkeys\PasskeyAuthenticatable; use Spatie\Permission\Traits\HasRoles; #[Hidden(['password'])] @@ -19,7 +21,7 @@ #[Appends(['full_name'])] class User extends Authenticatable { - use HasFactory, HasRoles, Notifiable, SoftDeletes; + use HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, SoftDeletes, TwoFactorAuthenticatable; protected function casts(): array { diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 402353f..5b659f7 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -2,9 +2,9 @@ import { createInertiaApp } from '@inertiajs/react'; import { Toaster } from '@/components/ui/sonner'; import { TooltipProvider } from '@/components/ui/tooltip'; import { initializeTheme } from '@/hooks/use-appearance'; +import SettingsLayout from '@/layouts/admin/settings/layout'; import AppLayout from '@/layouts/app-layout'; import AuthLayout from '@/layouts/auth-layout'; -import SettingsLayout from '@/layouts/settings/layout'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -16,7 +16,7 @@ createInertiaApp({ return null; case name.startsWith('auth/'): return AuthLayout; - case name.startsWith('settings/'): + case name.startsWith('admin/settings/'): return [AppLayout, SettingsLayout]; default: return AppLayout; diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 665ff60..18ce334 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -19,9 +19,12 @@ import { Mail, Megaphone, MessageCircle, + Palette, Receipt, School, + ShieldCheck, User, + UserCog, Users, } from 'lucide-react'; import * as React from 'react'; @@ -51,6 +54,9 @@ import { index as schedulesRoute } from '@/routes/admin/manage/schedules'; import { index as tuitionInvoicesRoute } from '@/routes/admin/manage/tuition-invoices'; import { index as academicTerm } from '@/routes/admin/master/academic-terms'; import { index as departmentsRoute } from '@/routes/admin/master/departments'; +import { edit as appearanceRoute } from '@/routes/admin/settings/appearance'; +import { edit as profileRoute } from '@/routes/admin/settings/profile'; +import { edit as securityRoute } from '@/routes/admin/settings/security'; import { index as administratorsRoute } from '@/routes/admin/users/administrators'; import { index as lecturersRoute } from '@/routes/admin/users/lecturers'; import { index as studentsRoute } from '@/routes/admin/users/students'; @@ -181,6 +187,26 @@ const data: { }, ], }, + { + label: 'Pengaturan', + items: [ + { + name: 'Profil', + url: profileRoute.url(), + icon: UserCog, + }, + { + name: 'Kata Sandi', + url: securityRoute.url(), + icon: ShieldCheck, + }, + { + name: 'Tampilan', + url: appearanceRoute.url(), + icon: Palette, + }, + ], + }, ], navSecondary: [ { diff --git a/resources/js/components/appearance-tabs.tsx b/resources/js/components/appearance-tabs.tsx index b013862..9b1bfcb 100644 --- a/resources/js/components/appearance-tabs.tsx +++ b/resources/js/components/appearance-tabs.tsx @@ -12,9 +12,9 @@ export default function AppearanceToggleTab({ const { appearance, updateAppearance } = useAppearance(); const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [ - { value: 'light', icon: Sun, label: 'Light' }, - { value: 'dark', icon: Moon, label: 'Dark' }, - { value: 'system', icon: Monitor, label: 'System' }, + { value: 'light', icon: Sun, label: 'Terang' }, + { value: 'dark', icon: Moon, label: 'Gelap' }, + { value: 'system', icon: Monitor, label: 'Sistem' }, ]; return ( diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx deleted file mode 100644 index 213e76f..0000000 --- a/resources/js/components/delete-user.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { Form } from '@inertiajs/react'; -import { useRef } from 'react'; -import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; -import Heading from '@/components/heading'; -import InputError from '@/components/input-error'; -import PasswordInput from '@/components/password-input'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { Label } from '@/components/ui/label'; - -export default function DeleteUser() { - const passwordInput = useRef(null); - - return ( -
- -
-
-

Warning

-

- Please proceed with caution, this cannot be undone. -

-
- - - - - - - - Are you sure you want to delete your account? - - - Once your account is deleted, all of its resources - and data will also be permanently deleted. Please - enter your password to confirm you would like to - permanently delete your account. - - -
passwordInput.current?.focus()} - resetOnSuccess - className="space-y-6" - > - {({ resetAndClearErrors, processing, errors }) => ( - <> -
- - - - - -
- - - - - - - - - - - )} -
-
-
-
-
- ); -} diff --git a/resources/js/components/manage-passkeys.tsx b/resources/js/components/manage-passkeys.tsx deleted file mode 100644 index 84c38d7..0000000 --- a/resources/js/components/manage-passkeys.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { router } from '@inertiajs/react'; -import { KeyRound } from 'lucide-react'; -import { destroy } from '@/actions/Laravel/Passkeys/Http/Controllers/PasskeyRegistrationController'; -import Heading from '@/components/heading'; -import PasskeyItem from '@/components/passkey-item'; -import PasskeyRegistration from '@/components/passkey-register'; -import type { Passkey } from '@/types/auth'; - -export type Props = { - canManagePasskeys?: boolean; - passkeys?: Passkey[]; -}; - -const EmptyState = () => { - return ( -
-
- -
-

No passkeys yet

-

- Add a passkey to sign in without a password -

-
- ); -}; - -export default function ManagePasskeys(props: Props) { - const passkeys = props.passkeys ?? []; - - const handleDelete = (id: number, onError: () => void) => { - router.delete(destroy.url(id), { - preserveScroll: true, - onError, - }); - }; - - const handleRegisterSuccess = () => { - router.reload(); - }; - - if (!(props.canManagePasskeys ?? false)) { - return null; - } - - return ( -
- - -
- {passkeys.length > 0 ? ( - passkeys.map((passkey) => ( - - )) - ) : ( - - )} -
- - -
- ); -} diff --git a/resources/js/components/manage-two-factor.tsx b/resources/js/components/manage-two-factor.tsx deleted file mode 100644 index 84099d3..0000000 --- a/resources/js/components/manage-two-factor.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Form } from '@inertiajs/react'; -import { ShieldCheck } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; -import Heading from '@/components/heading'; -import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes'; -import TwoFactorSetupModal from '@/components/two-factor-setup-modal'; -import { Button } from '@/components/ui/button'; -import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; -import { disable, enable } from '@/routes/two-factor'; - -export type Props = { - canManageTwoFactor?: boolean; - requiresConfirmation?: boolean; - twoFactorEnabled?: boolean; -}; - -export default function ManageTwoFactor(props: Props) { - const requiresConfirmation = props.requiresConfirmation ?? false; - const twoFactorEnabled = props.twoFactorEnabled ?? false; - - const { - qrCodeSvg, - hasSetupData, - manualSetupKey, - clearSetupData, - clearTwoFactorAuthData, - fetchSetupData, - recoveryCodesList, - fetchRecoveryCodes, - errors, - } = useTwoFactorAuth(); - const [showSetupModal, setShowSetupModal] = useState(false); - const prevTwoFactorEnabled = useRef(twoFactorEnabled); - - useEffect(() => { - if (prevTwoFactorEnabled.current && !twoFactorEnabled) { - clearTwoFactorAuthData(); - } - - prevTwoFactorEnabled.current = twoFactorEnabled; - }, [twoFactorEnabled, clearTwoFactorAuthData]); - - if (!(props.canManageTwoFactor ?? false)) { - return null; - } - - return ( -
- - {twoFactorEnabled ? ( -
-

- You will be prompted for a secure, random pin during - login, which you can retrieve from the TOTP-supported - application on your phone. -

- -
-
- {({ processing }) => ( - - )} -
-
- - -
- ) : ( -
-

- 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. -

- -
- {hasSetupData ? ( - - ) : ( -
setShowSetupModal(true)} - > - {({ processing }) => ( - - )} -
- )} -
-
- )} - - setShowSetupModal(false)} - requiresConfirmation={requiresConfirmation} - twoFactorEnabled={twoFactorEnabled} - qrCodeSvg={qrCodeSvg} - manualSetupKey={manualSetupKey} - clearSetupData={clearSetupData} - fetchSetupData={fetchSetupData} - errors={errors} - /> -
- ); -} diff --git a/resources/js/components/passkey-item.tsx b/resources/js/components/passkey-item.tsx deleted file mode 100644 index 7beccfb..0000000 --- a/resources/js/components/passkey-item.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { KeyRound, Trash2 } from 'lucide-react'; -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import type { Passkey } from '@/types/auth'; - -type Props = { - passkey: Passkey; - onDelete: (id: number, onError: () => void) => void; -}; - -export default function PasskeyItem({ passkey, onDelete }: Props) { - const [isDeleting, setIsDeleting] = useState(false); - - const handleDelete = () => { - setIsDeleting(true); - onDelete(passkey.id, () => setIsDeleting(false)); - }; - - return ( -
-
-
- -
-
-
-

- {passkey.name} -

- {passkey.authenticator && ( - - {passkey.authenticator} - - )} -
-

- Added {passkey.created_at_diff} - {passkey.last_used_at_diff && ( - <> - - / - - Last used {passkey.last_used_at_diff} - - )} -

-
-
- - - - - - - Remove passkey - - Are you sure you want to remove the "{passkey.name}" - passkey? You will no longer be able to use it to sign - in. - - - - - - - - - -
- ); -} diff --git a/resources/js/components/passkey-register.tsx b/resources/js/components/passkey-register.tsx deleted file mode 100644 index 438469a..0000000 --- a/resources/js/components/passkey-register.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { usePasskeyRegister } from '@laravel/passkeys/react'; -import { useState } from 'react'; -import InputError from '@/components/input-error'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; - -type Props = { - onSuccess: () => void; -}; - -export default function PasskeyRegistration({ onSuccess }: Props) { - const [name, setName] = useState(() => { - const ua = navigator.userAgent; - - const browser = [ - { pattern: /Edg|Edge/, name: 'Edge' }, - { pattern: /OPR|Opera|OPiOS/, name: 'Opera' }, - { pattern: /Firefox|FxiOS/, name: 'Firefox' }, - { pattern: /Chrome|CriOS/, name: 'Chrome' }, - { pattern: /Safari/, name: 'Safari' }, - ].find(({ pattern }) => pattern.test(ua))?.name; - - const os = [ - { pattern: /iPhone/, name: 'iPhone' }, - { pattern: /iPad|Macintosh(?=.*Mobile)/, name: 'iPad' }, - { pattern: /Android/, name: 'Android' }, - { pattern: /Mac/, name: 'Mac' }, - { pattern: /Windows/, name: 'Windows' }, - ].find(({ pattern }) => pattern.test(ua))?.name; - - return [browser, os].filter(Boolean).join(' on ') || ''; - }); - - const [showForm, setShowForm] = useState(false); - const { register, isLoading, error, isSupported } = usePasskeyRegister({ - onSuccess: () => { - setName(''); - setShowForm(false); - onSuccess(); - }, - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!name.trim()) { - return; - } - - await register(name); - }; - - const handleCancel = () => { - setShowForm(false); - setName(''); - }; - - if (!isSupported) { - return ( -
- Passkeys are not supported in this browser. -
- ); - } - - if (!showForm) { - return ( - - ); - } - - return ( -
-
- - setName(e.target.value)} - placeholder="e.g., MacBook Pro, iPhone" - className="mt-1 block w-full border-foreground/20" - autoFocus - /> -

- A name helps you identify this passkey later. -

-
- - {error && } - -
- - -
- - ); -} diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx deleted file mode 100644 index 557885a..0000000 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { Form } from '@inertiajs/react'; -import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import AlertError from '@/components/alert-error'; -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { regenerateRecoveryCodes } from '@/routes/two-factor'; - -type Props = { - recoveryCodesList: string[]; - fetchRecoveryCodes: () => Promise; - errors: string[]; -}; - -export default function TwoFactorRecoveryCodes({ - recoveryCodesList, - fetchRecoveryCodes, - errors, -}: Props) { - const [codesAreVisible, setCodesAreVisible] = useState(false); - const codesSectionRef = useRef(null); - const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible; - - const toggleCodesVisibility = useCallback(async () => { - if (!codesAreVisible && !recoveryCodesList.length) { - await fetchRecoveryCodes(); - } - - setCodesAreVisible(!codesAreVisible); - - if (!codesAreVisible) { - setTimeout(() => { - codesSectionRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }); - }); - } - }, [codesAreVisible, recoveryCodesList.length, fetchRecoveryCodes]); - - useEffect(() => { - if (!recoveryCodesList.length) { - fetchRecoveryCodes(); - } - }, [recoveryCodesList.length, fetchRecoveryCodes]); - - const RecoveryCodeIconComponent = codesAreVisible ? EyeOff : Eye; - - return ( - - - - - - Recovery codes let you regain access if you lose your 2FA - device. Store them in a secure password manager. - - - -
- - - {canRegenerateCodes && ( -
- {({ processing }) => ( - - )} -
- )} -
-
-
- {errors?.length ? ( - - ) : ( - <> -
- {recoveryCodesList.length ? ( - recoveryCodesList.map((code, index) => ( -
- {code} -
- )) - ) : ( -
- {Array.from( - { length: 8 }, - (_, index) => ( - - )} -
- -
-

- Each recovery code can be used once to - access your account and will be removed - after use. If you need more, click{' '} - - Regenerate codes - {' '} - above. -

-
- - )} -
-
- - - ); -} diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx deleted file mode 100644 index 433cf67..0000000 --- a/resources/js/components/two-factor-setup-modal.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import { Form } from '@inertiajs/react'; -import { REGEXP_ONLY_DIGITS } from 'input-otp'; -import { Check, Copy, ScanLine } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import AlertError from '@/components/alert-error'; -import InputError from '@/components/input-error'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from '@/components/ui/input-otp'; -import { Spinner } from '@/components/ui/spinner'; -import { useAppearance } from '@/hooks/use-appearance'; -import { useClipboard } from '@/hooks/use-clipboard'; -import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth'; -import { confirm } from '@/routes/two-factor'; - -function GridScanIcon() { - return ( -
-
-
- {Array.from({ length: 5 }, (_, i) => ( -
- ))} -
-
- {Array.from({ length: 5 }, (_, i) => ( -
- ))} -
- -
-
- ); -} - -function TwoFactorSetupStep({ - qrCodeSvg, - manualSetupKey, - buttonText, - onNextStep, - errors, -}: { - qrCodeSvg: string | null; - manualSetupKey: string | null; - buttonText: string; - onNextStep: () => void; - errors: string[]; -}) { - const { resolvedAppearance } = useAppearance(); - const [copiedText, copy] = useClipboard(); - const IconComponent = copiedText === manualSetupKey ? Check : Copy; - - return ( - <> - {errors?.length ? ( - - ) : ( - <> -
-
-
- {qrCodeSvg ? ( -
- ) : ( - - )} -
-
-
- -
- -
- -
-
- - or, enter the code manually - -
- -
-
- {!manualSetupKey ? ( -
- -
- ) : ( - <> - - - - )} -
-
- - )} - - ); -} - -function TwoFactorVerificationStep({ - onClose, - onBack, -}: { - onClose: () => void; - onBack: () => void; -}) { - const [code, setCode] = useState(''); - const pinInputContainerRef = useRef(null); - - useEffect(() => { - setTimeout(() => { - pinInputContainerRef.current?.querySelector('input')?.focus(); - }, 0); - }, []); - - return ( -
onClose()} - resetOnError - resetOnSuccess - > - {({ - processing, - errors, - }: { - processing: boolean; - errors?: { confirmTwoFactorAuthentication?: { code?: string } }; - }) => ( - <> -
-
- - - {Array.from( - { length: OTP_MAX_LENGTH }, - (_, index) => ( - - ), - )} - - - -
- -
- - -
-
- - )} -
- ); -} - -type Props = { - isOpen: boolean; - onClose: () => void; - requiresConfirmation: boolean; - twoFactorEnabled: boolean; - qrCodeSvg: string | null; - manualSetupKey: string | null; - clearSetupData: () => void; - fetchSetupData: () => Promise; - errors: string[]; -}; - -export default function TwoFactorSetupModal({ - isOpen, - onClose, - requiresConfirmation, - twoFactorEnabled, - qrCodeSvg, - manualSetupKey, - clearSetupData, - fetchSetupData, - errors, -}: Props) { - const [showVerificationStep, setShowVerificationStep] = - useState(false); - - const modalConfig = useMemo<{ - title: string; - description: string; - buttonText: string; - }>(() => { - if (twoFactorEnabled) { - return { - title: 'Two-factor authentication enabled', - description: - 'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.', - buttonText: 'Close', - }; - } - - if (showVerificationStep) { - return { - title: 'Verify authentication code', - description: - 'Enter the 6-digit code from your authenticator app', - buttonText: 'Continue', - }; - } - - return { - title: 'Enable two-factor authentication', - description: - 'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app', - buttonText: 'Continue', - }; - }, [twoFactorEnabled, showVerificationStep]); - - const resetModalState = useCallback(() => { - if (twoFactorEnabled) { - clearSetupData(); - } - - setShowVerificationStep(false); - }, [clearSetupData, twoFactorEnabled]); - - const handleClose = useCallback(() => { - resetModalState(); - onClose(); - }, [onClose, resetModalState]); - - const handleModalNextStep = useCallback(() => { - if (requiresConfirmation) { - setShowVerificationStep(true); - - return; - } - - clearSetupData(); - handleClose(); - }, [requiresConfirmation, clearSetupData, handleClose]); - - const fetchSetupDataRef = useRef(fetchSetupData); - - useEffect(() => { - fetchSetupDataRef.current = fetchSetupData; - }, [fetchSetupData]); - - useEffect(() => { - if (isOpen && !qrCodeSvg) { - fetchSetupDataRef.current(); - } - }, [isOpen, qrCodeSvg]); - - return ( - !open && handleClose()}> - - - - {modalConfig.title} - - {modalConfig.description} - - - -
- {showVerificationStep ? ( - setShowVerificationStep(false)} - /> - ) : ( - - )} -
-
-
- ); -} diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index 59e8a71..932d3b7 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -1,14 +1,14 @@ +import { Link, router } from '@inertiajs/react'; +import { LogOut, Settings } from 'lucide-react'; import { DropdownMenuGroup, DropdownMenuItem, - DropdownMenuSeparator + DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { useMobileNavigation } from '@/hooks/use-mobile-navigation'; import { logout } from '@/routes'; -import { edit } from '@/routes/profile'; +import { edit } from '@/routes/admin/settings/profile'; import type { User } from '@/types'; -import { Link, router } from '@inertiajs/react'; -import { LogOut, Settings } from 'lucide-react'; type Props = { user: User; diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/admin/settings/layout.tsx similarity index 84% rename from resources/js/layouts/settings/layout.tsx rename to resources/js/layouts/admin/settings/layout.tsx index efd7904..8e2abc1 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/admin/settings/layout.tsx @@ -5,24 +5,24 @@ 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 } from '@/routes/profile'; -import { edit as editSecurity } from '@/routes/security'; +import { edit as editAppearance } from '@/routes/admin/settings/appearance'; +import { edit } from '@/routes/admin/settings/profile'; +import { edit as editSecurity } from '@/routes/admin/settings/security'; import type { NavItem } from '@/types'; 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, }, @@ -34,15 +34,15 @@ export default function SettingsLayout({ children }: PropsWithChildren) { return (