Refactor admin settings structure and implement profile and security management
Some checks failed
tests / ci (pull_request) Has been cancelled

- Moved profile and security settings from the general settings route to a dedicated admin settings route.
- Created new components and pages for managing user profile and security settings.
- Updated user menu to link to the new admin settings pages.
- Removed old settings pages and routes that are no longer in use.
- Added tests for profile and security settings functionality.
This commit is contained in:
Yoga Pangestu 2026-08-25 17:57:47 +07:00
parent 13ccb4d19c
commit 70d37741ef
34 changed files with 824 additions and 1649 deletions

View File

@ -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'],
];
}

View File

@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Settings\ProfileDeleteRequest;
use App\Http\Requests\Admin\Settings\ProfileUpdateRequest;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
$user = $request->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(),
]];
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Settings\PasswordUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class SecurityController extends Controller
{
/**
* Show the user's security settings page.
*/
public function edit(): Response
{
return Inertia::render('admin/settings/security', [
'passwordRules' => 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();
}
}

View File

@ -1,62 +0,0 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileDeleteRequest;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $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('/');
}
}

View File

@ -1,66 +0,0 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\PasswordUpdateRequest;
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
class SecurityController extends Controller
{
/**
* Show the user's security settings page.
*/
public function edit(TwoFactorAuthenticationRequest $request): Response
{
$props = [
'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(),
'canManagePasskeys' => Features::canManagePasskeys(),
'passkeys' => Features::canManagePasskeys()
? $request->user()
->passkeys()
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
->latest()
->get()
->map(fn ($passkey) => [
'id' => $passkey->id,
'name' => $passkey->name,
'authenticator' => $passkey->authenticator,
'created_at_diff' => $passkey->created_at->diffForHumans(),
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
])
->values()
->all()
: [],
'passwordRules' => Password::defaults()->toPasswordRulesString(),
];
if (Features::canManageTwoFactorAuthentication()) {
$request->ensureStateIsValid();
$props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication();
$props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
return Inertia::render('settings/security', $props);
}
/**
* 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();
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Requests\Settings;
namespace App\Http\Requests\Admin\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Requests\Settings;
namespace App\Http\Requests\Admin\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Requests\Settings;
namespace App\Http\Requests\Admin\Settings;
use App\Concerns\ProfileValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;

View File

@ -1,22 +0,0 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Fortify\InteractsWithTwoFactorState;
class TwoFactorAuthenticationRequest extends FormRequest
{
use InteractsWithTwoFactorState;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [];
}
}

View File

@ -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
{

View File

@ -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;

View File

@ -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: [
{

View File

@ -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 (

View File

@ -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<HTMLInputElement>(null);
return (
<div className="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
/>
<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">
<p className="font-medium">Warning</p>
<p className="text-sm">
Please proceed with caution, this cannot be undone.
</p>
</div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="destructive"
data-test="delete-user-button"
>
Delete account
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
Are you sure you want to delete your account?
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
<Form
{...ProfileController.destroy.form()}
options={{
preserveScroll: true,
}}
onError={() => passwordInput.current?.focus()}
resetOnSuccess
className="space-y-6"
>
{({ resetAndClearErrors, processing, errors }) => (
<>
<div className="grid gap-2">
<Label
htmlFor="password"
className="sr-only"
>
Password
</Label>
<PasswordInput
id="password"
name="password"
ref={passwordInput}
placeholder="Password"
autoComplete="current-password"
/>
<InputError message={errors.password} />
</div>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button
variant="secondary"
onClick={() =>
resetAndClearErrors()
}
>
Cancel
</Button>
</DialogClose>
<Button
variant="destructive"
disabled={processing}
asChild
>
<button
type="submit"
data-test="confirm-delete-user-button"
>
Delete account
</button>
</Button>
</DialogFooter>
</>
)}
</Form>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@ -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 (
<div className="p-8 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
<KeyRound className="h-7 w-7 text-muted-foreground" />
</div>
<p className="font-medium">No passkeys yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Add a passkey to sign in without a password
</p>
</div>
);
};
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 (
<div className="space-y-6">
<Heading
variant="small"
title="Passkeys"
description="Manage your passkeys for passwordless sign-in"
/>
<div className="overflow-hidden rounded-lg border border-border">
{passkeys.length > 0 ? (
passkeys.map((passkey) => (
<PasskeyItem
key={passkey.id}
passkey={passkey}
onDelete={handleDelete}
/>
))
) : (
<EmptyState />
)}
</div>
<PasskeyRegistration onSuccess={handleRegisterSuccess} />
</div>
);
}

View File

@ -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<boolean>(false);
const prevTwoFactorEnabled = useRef(twoFactorEnabled);
useEffect(() => {
if (prevTwoFactorEnabled.current && !twoFactorEnabled) {
clearTwoFactorAuthData();
}
prevTwoFactorEnabled.current = twoFactorEnabled;
}, [twoFactorEnabled, clearTwoFactorAuthData]);
if (!(props.canManageTwoFactor ?? false)) {
return null;
}
return (
<div className="space-y-6">
<Heading
variant="small"
title="Two-factor authentication"
description="Manage your two-factor authentication settings"
/>
{twoFactorEnabled ? (
<div className="flex flex-col items-start justify-start space-y-4">
<p className="text-sm text-muted-foreground">
You will be prompted for a secure, random pin 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>
);
}

View File

@ -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 (
<div className="flex items-center justify-between border-b p-4 last:border-b-0">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<KeyRound className="h-5 w-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<p className="font-medium tracking-tight">
{passkey.name}
</p>
{passkey.authenticator && (
<span className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase ring-1 ring-border ring-inset">
{passkey.authenticator}
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
Added {passkey.created_at_diff}
{passkey.last_used_at_diff && (
<>
<span className="mx-1 text-muted-foreground/50">
/
</span>
Last used {passkey.last_used_at_diff}
</>
)}
</p>
</div>
</div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Remove passkey</DialogTitle>
<DialogDescription>
Are you sure you want to remove the "{passkey.name}"
passkey? You will no longer be able to use it to sign
in.
</DialogDescription>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Removing...' : 'Remove passkey'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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 (
<div className="text-sm text-muted-foreground">
Passkeys are not supported in this browser.
</div>
);
}
if (!showForm) {
return (
<Button variant="outline" onClick={() => setShowForm(true)}>
Add passkey
</Button>
);
}
return (
<form
onSubmit={handleSubmit}
className="space-y-4 rounded-lg border border-border bg-muted/50 p-4"
>
<div className="grid gap-2">
<Label htmlFor="passkey-name">Passkey name</Label>
<Input
id="passkey-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., MacBook Pro, iPhone"
className="mt-1 block w-full border-foreground/20"
autoFocus
/>
<p className="text-xs text-muted-foreground">
A name helps you identify this passkey later.
</p>
</div>
{error && <InputError message={error} />}
<div className="flex gap-2">
<Button type="submit" disabled={isLoading || !name.trim()}>
{isLoading ? 'Registering...' : 'Register passkey'}
</Button>
<Button type="button" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
</div>
</form>
);
}

View File

@ -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<void>;
errors: string[];
};
export default function TwoFactorRecoveryCodes({
recoveryCodesList,
fetchRecoveryCodes,
errors,
}: Props) {
const [codesAreVisible, setCodesAreVisible] = useState<boolean>(false);
const codesSectionRef = useRef<HTMLDivElement | null>(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 (
<Card>
<CardHeader>
<CardTitle className="flex gap-3">
<LockKeyhole className="size-4" aria-hidden="true" />
2FA recovery codes
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 select-none sm:flex-row sm:items-center sm:justify-between">
<Button
onClick={toggleCodesVisibility}
className="w-fit"
aria-expanded={codesAreVisible}
aria-controls="recovery-codes-section"
>
<RecoveryCodeIconComponent
className="size-4"
aria-hidden="true"
/>
{codesAreVisible ? 'Hide' : 'View'} recovery codes
</Button>
{canRegenerateCodes && (
<Form
{...regenerateRecoveryCodes.form()}
options={{ preserveScroll: true }}
onSuccess={fetchRecoveryCodes}
>
{({ processing }) => (
<Button
variant="secondary"
type="submit"
disabled={processing}
aria-describedby="regenerate-warning"
>
<RefreshCw /> Regenerate codes
</Button>
)}
</Form>
)}
</div>
<div
id="recovery-codes-section"
className={`relative overflow-hidden transition-all duration-300 ${codesAreVisible ? 'h-auto opacity-100' : 'h-0 opacity-0'}`}
aria-hidden={!codesAreVisible}
>
<div className="mt-3 space-y-3">
{errors?.length ? (
<AlertError errors={errors} />
) : (
<>
<div
ref={codesSectionRef}
className="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
role="list"
aria-label="Recovery codes"
>
{recoveryCodesList.length ? (
recoveryCodesList.map((code, index) => (
<div
key={index}
role="listitem"
className="select-text"
>
{code}
</div>
))
) : (
<div
className="space-y-2"
aria-label="Loading recovery codes"
>
{Array.from(
{ length: 8 },
(_, index) => (
<div
key={index}
className="h-4 animate-pulse rounded bg-muted-foreground/20"
aria-hidden="true"
/>
),
)}
</div>
)}
</div>
<div className="text-xs text-muted-foreground select-none">
<p id="regenerate-warning">
Each recovery code can be used once to
access your account and will be removed
after use. If you need more, click{' '}
<span className="font-bold">
Regenerate codes
</span>{' '}
above.
</p>
</div>
</>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -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 (
<div className="mb-3 rounded-full border border-border bg-card p-0.5 shadow-sm">
<div className="relative overflow-hidden rounded-full border border-border bg-muted p-2.5">
<div className="absolute inset-0 grid grid-cols-5 opacity-50">
{Array.from({ length: 5 }, (_, i) => (
<div
key={`col-${i + 1}`}
className="border-r border-border last:border-r-0"
/>
))}
</div>
<div className="absolute inset-0 grid grid-rows-5 opacity-50">
{Array.from({ length: 5 }, (_, i) => (
<div
key={`row-${i + 1}`}
className="border-b border-border last:border-b-0"
/>
))}
</div>
<ScanLine className="relative z-20 size-6 text-foreground" />
</div>
</div>
);
}
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 ? (
<AlertError errors={errors} />
) : (
<>
<div className="mx-auto flex max-w-md overflow-hidden">
<div className="mx-auto aspect-square w-64 rounded-lg border border-border">
<div className="z-10 flex h-full w-full items-center justify-center p-5">
{qrCodeSvg ? (
<div
className="aspect-square w-full rounded-lg bg-white p-2 [&_svg]:size-full"
dangerouslySetInnerHTML={{
__html: qrCodeSvg,
}}
style={{
filter:
resolvedAppearance === 'dark'
? 'invert(1) brightness(1.5)'
: undefined,
}}
/>
) : (
<Spinner />
)}
</div>
</div>
</div>
<div className="flex w-full space-x-5">
<Button className="w-full" onClick={onNextStep}>
{buttonText}
</Button>
</div>
<div className="relative flex w-full items-center justify-center">
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
<span className="relative bg-card px-2 py-1">
or, enter the code manually
</span>
</div>
<div className="flex w-full space-x-2">
<div className="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
{!manualSetupKey ? (
<div className="flex h-full w-full items-center justify-center bg-muted p-3">
<Spinner />
</div>
) : (
<>
<input
type="text"
readOnly
value={manualSetupKey}
className="h-full w-full bg-background p-3 text-foreground outline-none"
/>
<button
onClick={() => copy(manualSetupKey)}
className="border-l border-border px-3 hover:bg-muted"
>
<IconComponent className="w-4" />
</button>
</>
)}
</div>
</div>
</>
)}
</>
);
}
function TwoFactorVerificationStep({
onClose,
onBack,
}: {
onClose: () => void;
onBack: () => void;
}) {
const [code, setCode] = useState<string>('');
const pinInputContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setTimeout(() => {
pinInputContainerRef.current?.querySelector('input')?.focus();
}, 0);
}, []);
return (
<Form
{...confirm.form()}
onSuccess={() => onClose()}
resetOnError
resetOnSuccess
>
{({
processing,
errors,
}: {
processing: boolean;
errors?: { confirmTwoFactorAuthentication?: { code?: string } };
}) => (
<>
<div
ref={pinInputContainerRef}
className="relative w-full space-y-3"
>
<div className="flex w-full flex-col items-center space-y-3 py-2">
<InputOTP
id="otp"
name="code"
maxLength={OTP_MAX_LENGTH}
onChange={setCode}
disabled={processing}
pattern={REGEXP_ONLY_DIGITS}
autoFocus
>
<InputOTPGroup>
{Array.from(
{ length: OTP_MAX_LENGTH },
(_, index) => (
<InputOTPSlot
key={index}
index={index}
/>
),
)}
</InputOTPGroup>
</InputOTP>
<InputError
message={
errors?.confirmTwoFactorAuthentication?.code
}
/>
</div>
<div className="flex w-full space-x-5">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={onBack}
disabled={processing}
>
Back
</Button>
<Button
type="submit"
className="flex-1"
disabled={
processing || code.length < OTP_MAX_LENGTH
}
>
Confirm
</Button>
</div>
</div>
</>
)}
</Form>
);
}
type Props = {
isOpen: boolean;
onClose: () => void;
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
qrCodeSvg: string | null;
manualSetupKey: string | null;
clearSetupData: () => void;
fetchSetupData: () => Promise<void>;
errors: string[];
};
export default function TwoFactorSetupModal({
isOpen,
onClose,
requiresConfirmation,
twoFactorEnabled,
qrCodeSvg,
manualSetupKey,
clearSetupData,
fetchSetupData,
errors,
}: Props) {
const [showVerificationStep, setShowVerificationStep] =
useState<boolean>(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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="flex items-center justify-center">
<GridScanIcon />
<DialogTitle>{modalConfig.title}</DialogTitle>
<DialogDescription className="text-center">
{modalConfig.description}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center space-y-5">
{showVerificationStep ? (
<TwoFactorVerificationStep
onClose={handleClose}
onBack={() => setShowVerificationStep(false)}
/>
) : (
<TwoFactorSetupStep
qrCodeSvg={qrCodeSvg}
manualSetupKey={manualSetupKey}
buttonText={modalConfig.buttonText}
onNextStep={handleModalNextStep}
errors={errors}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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;

View File

@ -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 (
<div className="px-4 py-6">
<Heading
title="Settings"
description="Manage your profile and account settings"
title="Pengaturan"
description="Kelola profil dan pengaturan akun Anda"
/>
<div className="flex flex-col lg:flex-row lg:space-x-12">
<aside className="w-full max-w-xl lg:w-48">
<nav
className="flex flex-col space-y-1 space-x-0"
aria-label="Settings"
aria-label="Pengaturan"
>
{sidebarNavItems.map((item, index) => (
<Button

View File

@ -1,20 +1,20 @@
import { Head } from '@inertiajs/react';
import AppearanceTabs from '@/components/appearance-tabs';
import Heading from '@/components/heading';
import { edit as editAppearance } from '@/routes/appearance';
import { edit as editAppearance } from '@/routes/admin/settings/appearance';
export default function Appearance() {
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">
<Heading
variant="small"
title="Appearance settings"
description="Update the appearance settings for your account"
title="Pengaturan tampilan"
description="Perbarui pengaturan tampilan untuk akun Anda"
/>
<AppearanceTabs />
</div>
@ -25,7 +25,7 @@ export default function Appearance() {
Appearance.layout = {
breadcrumbs: [
{
title: 'Appearance settings',
title: 'Pengaturan Tampilan',
href: editAppearance(),
},
],

View File

@ -0,0 +1,389 @@
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { format } from 'date-fns';
import { useState } from 'react';
import ProfileController from '@/actions/App/Http/Controllers/Admin/Settings/ProfileController';
import { DatePicker } from '@/components/date-picker';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PhoneInput } from '@/components/ui/phone-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { edit } from '@/routes/admin/settings/profile';
import { send } from '@/routes/verification';
import type { Auth } from '@/types';
const roleLabels: Record<string, string> = {
mahasiswa: 'Mahasiswa',
dosen: 'Dosen',
'staff-admin': 'Staf Admin',
'staff-keuangan': 'Staf Keuangan',
};
type Profile = {
full_name: string | null;
phone_number: string | null;
address: string | null;
gender: 'male' | 'female' | null;
birth_date: string | null;
birth_place: string | null;
} | null;
type StudentRoleData = {
student_number: string;
department: string | null;
enrollment_year: number;
academic_advisor: string | null;
};
type LecturerRoleData = {
lecturer_number: string;
department: string | null;
};
type AdminRoleData = {
roles: string[];
};
type PageProps = {
auth: Auth;
};
type Props = {
mustVerifyEmail: boolean;
status?: string;
profile: Profile;
role: 'mahasiswa' | 'dosen' | 'admin';
roleData: StudentRoleData | LecturerRoleData | AdminRoleData | null;
};
export default function Profile({
mustVerifyEmail,
status,
profile,
role,
roleData,
}: Props) {
const { auth } = usePage<PageProps>().props;
const [gender, setGender] = useState(profile?.gender ?? '');
const [birthDate, setBirthDate] = useState<Date | undefined>(
profile?.birth_date ? new Date(profile.birth_date) : undefined,
);
return (
<>
<Head title="Pengaturan Profil" />
<h1 className="sr-only">Pengaturan Profil</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Profil"
description="Perbarui nama pengguna dan alamat email Anda"
/>
<Form
{...ProfileController.update.form()}
options={{
preserveScroll: true,
}}
className="space-y-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="username">
Nama pengguna
</Label>
<Input
id="username"
defaultValue={auth.user.username}
name="username"
required
autoComplete="username"
placeholder="Nama pengguna"
/>
<InputError message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">Alamat email</Label>
<Input
id="email"
type="email"
defaultValue={auth.user.email}
name="email"
required
autoComplete="email"
placeholder="Alamat email"
/>
<InputError message={errors.email} />
</div>
</div>
{mustVerifyEmail &&
auth.user.email_verified_at === null && (
<div>
<p className="-mt-2 text-sm text-muted-foreground">
Alamat email Anda belum
diverifikasi.{' '}
<Link
href={send()}
as="button"
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Klik di sini untuk mengirim
ulang email verifikasi.
</Link>
</p>
{status ===
'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">
Tautan verifikasi baru telah
dikirim ke alamat email Anda.
</div>
)}
</div>
)}
<div className="grid gap-4 border-t pt-6 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="full_name">
Nama Lengkap
</Label>
<Input
id="full_name"
name="full_name"
defaultValue={profile?.full_name ?? ''}
placeholder="Masukkan nama lengkap"
/>
<InputError message={errors.full_name} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone_number">
Nomor Telepon
</Label>
<PhoneInput
id="phone_number"
name="phone_number"
value={profile?.phone_number ?? ''}
placeholder="08xx xxxx xxxx"
/>
<InputError message={errors.phone_number} />
</div>
<div className="grid gap-4 md:col-span-2 md:grid-cols-3">
<div className="grid gap-2">
<Label>Jenis Kelamin</Label>
<input
type="hidden"
name="gender"
value={gender}
/>
<RadioGroup
value={gender}
onValueChange={setGender}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem
value="male"
id="male"
/>
<Label
htmlFor="male"
className="font-normal"
>
Laki-laki
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
value="female"
id="female"
/>
<Label
htmlFor="female"
className="font-normal"
>
Perempuan
</Label>
</div>
</RadioGroup>
<InputError message={errors.gender} />
</div>
<div className="grid gap-2">
<Label>Tempat Lahir</Label>
<Input
name="birth_place"
defaultValue={
profile?.birth_place ?? ''
}
placeholder="Masukkan tempat lahir"
/>
<InputError
message={errors.birth_place}
/>
</div>
<div className="grid gap-2">
<Label>Tanggal Lahir</Label>
<input
type="hidden"
name="birth_date"
value={
birthDate
? format(
birthDate,
'yyyy-MM-dd',
)
: ''
}
/>
<DatePicker
value={birthDate}
onChange={setBirthDate}
/>
<InputError
message={errors.birth_date}
/>
</div>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">Alamat</Label>
<Textarea
id="address"
name="address"
defaultValue={profile?.address ?? ''}
placeholder="Masukkan alamat"
/>
<InputError message={errors.address} />
</div>
</div>
{role === 'mahasiswa' && roleData && (
<div className="grid gap-4 border-t pt-6 md:grid-cols-2">
<div className="grid gap-2">
<Label>NIM</Label>
<Input
disabled
value={
(roleData as StudentRoleData)
.student_number
}
/>
</div>
<div className="grid gap-2">
<Label>Jurusan</Label>
<Input
disabled
value={
(roleData as StudentRoleData)
.department ?? '-'
}
/>
</div>
<div className="grid gap-2">
<Label>Tahun Masuk</Label>
<Input
disabled
value={
(roleData as StudentRoleData)
.enrollment_year
}
/>
</div>
<div className="grid gap-2">
<Label>Dosen Wali</Label>
<Input
disabled
value={
(roleData as StudentRoleData)
.academic_advisor ?? '-'
}
/>
</div>
<p className="text-xs text-muted-foreground md:col-span-2">
Informasi akademik dikelola oleh admin
dan tidak dapat diubah sendiri.
</p>
</div>
)}
{role === 'dosen' && roleData && (
<div className="grid gap-4 border-t pt-6 md:grid-cols-2">
<div className="grid gap-2">
<Label>NIDN</Label>
<Input
disabled
value={
(roleData as LecturerRoleData)
.lecturer_number
}
/>
</div>
<div className="grid gap-2">
<Label>Jurusan</Label>
<Input
disabled
value={
(roleData as LecturerRoleData)
.department ?? '-'
}
/>
</div>
<p className="text-xs text-muted-foreground md:col-span-2">
Informasi akademik dikelola oleh admin
dan tidak dapat diubah sendiri.
</p>
</div>
)}
{role === 'admin' && roleData && (
<div className="grid gap-4 border-t pt-6">
<div className="grid gap-2">
<Label>Peran</Label>
<Input
disabled
value={(
roleData as AdminRoleData
).roles
.map((r) => roleLabels[r] ?? r)
.join(', ')}
/>
</div>
</div>
)}
<div className="flex items-center gap-4">
<Button
disabled={processing}
data-test="update-profile-button"
>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}
Profile.layout = {
breadcrumbs: [
{
title: 'Pengaturan Profil',
href: edit(),
},
],
};

View File

@ -0,0 +1,134 @@
import { Form, Head } from '@inertiajs/react';
import { useRef } from 'react';
import SecurityController from '@/actions/App/Http/Controllers/Admin/Settings/SecurityController';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { edit } from '@/routes/admin/settings/security';
type Props = {
passwordRules: string;
};
export default function Security({ passwordRules }: Props) {
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
return (
<>
<Head title="Pengaturan Kata Sandi" />
<h1 className="sr-only">Pengaturan Kata Sandi</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Kata Sandi"
description="Pastikan akun Anda menggunakan kata sandi yang panjang dan acak agar tetap aman"
/>
<Form
{...SecurityController.update.form()}
options={{
preserveScroll: true,
}}
resetOnError={[
'password',
'password_confirmation',
'current_password',
]}
resetOnSuccess
onError={(errors) => {
if (errors.password) {
passwordInput.current?.focus();
}
if (errors.current_password) {
currentPasswordInput.current?.focus();
}
}}
className="space-y-6"
>
{({ errors, processing }) => (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="current_password">
Kata Sandi Saat Ini
</Label>
<PasswordInput
id="current_password"
ref={currentPasswordInput}
name="current_password"
autoComplete="current-password"
placeholder="Kata sandi saat ini"
/>
<InputError
message={errors.current_password}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">
Kata Sandi Baru
</Label>
<PasswordInput
id="password"
ref={passwordInput}
name="password"
autoComplete="new-password"
placeholder="Kata sandi baru"
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Konfirmasi Kata Sandi
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
autoComplete="new-password"
placeholder="Konfirmasi kata sandi"
passwordrules={passwordRules}
/>
<InputError
message={errors.password_confirmation}
/>
</div>
</div>
<div className="flex items-center gap-4">
<Button
disabled={processing}
data-test="update-password-button"
>
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}
Security.layout = {
breadcrumbs: [
{
title: 'Pengaturan Kata Sandi',
href: edit(),
},
],
};

View File

@ -1,138 +0,0 @@
import { Form, Head, usePage } from '@inertiajs/react';
import { Link } from '@inertiajs/react';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/delete-user';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
import type { Auth } from '@/types';
type PageProps = {
auth: Auth;
};
export default function Profile({
mustVerifyEmail,
status,
}: {
mustVerifyEmail: boolean;
status?: string;
}) {
const { auth } = usePage<PageProps>().props;
return (
<>
<Head title="Profile settings" />
<h1 className="sr-only">Profile settings</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Profile"
description="Update your name and email address"
/>
<Form
{...ProfileController.update.form()}
options={{
preserveScroll: true,
}}
className="space-y-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="mt-1 block w-full"
defaultValue={auth.user.name}
name="name"
required
autoComplete="name"
placeholder="Full name"
/>
<InputError
className="mt-2"
message={errors.name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
className="mt-1 block w-full"
defaultValue={auth.user.email}
name="email"
required
autoComplete="username"
placeholder="Email address"
/>
<InputError
className="mt-2"
message={errors.email}
/>
</div>
{mustVerifyEmail &&
auth.user.email_verified_at === null && (
<div>
<p className="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.{' '}
<Link
href={send()}
as="button"
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to re-send the
verification email.
</Link>
</p>
{status ===
'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">
A new verification link has been
sent to your email address.
</div>
)}
</div>
)}
<div className="flex items-center gap-4">
<Button
disabled={processing}
data-test="update-profile-button"
>
Save
</Button>
</div>
</>
)}
</Form>
</div>
<DeleteUser />
</>
);
}
Profile.layout = {
breadcrumbs: [
{
title: 'Profile settings',
href: edit(),
},
],
};

View File

@ -1,147 +0,0 @@
import { Form, Head } from '@inertiajs/react';
import { useRef } from 'react';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import type { Props as ManagePasskeysProps } from '@/components/manage-passkeys';
import ManagePasskeys from '@/components/manage-passkeys';
import type { Props as ManageTwoFactorProps } from '@/components/manage-two-factor';
import ManageTwoFactor from '@/components/manage-two-factor';
import PasswordInput from '@/components/password-input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { edit } from '@/routes/security';
type Props = {
passwordRules: string;
} & ManagePasskeysProps &
ManageTwoFactorProps;
export default function Security(props: Props) {
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
return (
<>
<Head title="Security settings" />
<h1 className="sr-only">Security settings</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
/>
<Form
{...SecurityController.update.form()}
options={{
preserveScroll: true,
}}
resetOnError={[
'password',
'password_confirmation',
'current_password',
]}
resetOnSuccess
onError={(errors) => {
if (errors.password) {
passwordInput.current?.focus();
}
if (errors.current_password) {
currentPasswordInput.current?.focus();
}
}}
className="space-y-6"
>
{({ errors, processing }) => (
<>
<div className="grid gap-2">
<Label htmlFor="current_password">
Current password
</Label>
<PasswordInput
id="current_password"
ref={currentPasswordInput}
name="current_password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
/>
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<PasswordInput
id="password"
ref={passwordInput}
name="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
passwordrules={props.passwordRules}
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
passwordrules={props.passwordRules}
/>
<InputError
message={errors.password_confirmation}
/>
</div>
<div className="flex items-center gap-4">
<Button
disabled={processing}
data-test="update-password-button"
>
Save
</Button>
</div>
</>
)}
</Form>
</div>
<ManageTwoFactor
canManageTwoFactor={props.canManageTwoFactor}
requiresConfirmation={props.requiresConfirmation}
twoFactorEnabled={props.twoFactorEnabled}
/>
<ManagePasskeys
canManagePasskeys={props.canManagePasskeys}
passkeys={props.passkeys}
/>
</>
);
}
Security.layout = {
breadcrumbs: [
{
title: 'Security settings',
href: edit(),
},
],
};

View File

@ -1,5 +1,6 @@
export type User = {
id: number;
username: string;
full_name: string;
email: string;
avatar?: string;

View File

@ -16,6 +16,8 @@
use App\Http\Controllers\Admin\Manage\TuitionPaymentController;
use App\Http\Controllers\Admin\Master\AcademicTermController;
use App\Http\Controllers\Admin\Master\DepartmentController;
use App\Http\Controllers\Admin\Settings\ProfileController;
use App\Http\Controllers\Admin\Settings\SecurityController;
use App\Http\Controllers\Admin\Users\AdministratorController;
use App\Http\Controllers\Admin\Users\LecturerController;
use App\Http\Controllers\Admin\Users\StudentController;
@ -81,6 +83,23 @@
Route::resource('academic-advising-logs', AcademicAdvisingLogController::class)->except(['create', 'edit', 'show']);
});
Route::prefix('admin/settings')->name('admin.settings.')->group(function () {
Route::redirect('/', '/admin/settings/profile');
Route::get('profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('security', [SecurityController::class, 'edit'])
->name('security.edit');
Route::put('password', [SecurityController::class, 'update'])
->middleware('throttle:6,1')
->name('password.update');
Route::inertia('appearance', 'admin/settings/appearance')->name('appearance.edit');
});
Route::prefix('admin/users')->name('admin.users.')->group(function () {
Route::resource('lecturers', LecturerController::class)->except(['show'])->parameters(['lecturers' => 'user']);
Route::patch('lecturers/{user}/reset-password', [LecturerController::class, 'resetPassword'])->name('lecturers.reset_password');

View File

@ -1,34 +0,0 @@
<?php
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\SecurityController;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->group(function () {
Route::redirect('settings', '/settings/profile');
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('settings/security', [SecurityController::class, 'edit'])
->middleware(RequirePassword::class)
->name('security.edit');
Route::put('settings/password', [SecurityController::class, 'update'])
->middleware('throttle:6,1')
->name('user-password.update');
Route::inertia('settings/appearance', 'settings/appearance')->name('appearance.edit');
});
Route::get('.well-known/passkey-endpoints', function () {
return response()->json([
'enroll' => route('security.edit'),
'manage' => route('security.edit'),
]);
})->name('well-known.passkeys');

View File

@ -19,5 +19,11 @@
Route::resource('feedback', FeedbackController::class)->except(['create', 'edit', 'show']);
});
require __DIR__.'/settings.php';
Route::get('.well-known/passkey-endpoints', function () {
return response()->json([
'enroll' => route('admin.settings.security.edit'),
'manage' => route('admin.settings.security.edit'),
]);
})->name('well-known.passkeys');
require __DIR__.'/admin.php';

View File

@ -7,7 +7,7 @@
$response = $this
->actingAs($user)
->get(route('profile.edit'));
->get(route('admin.settings.profile.edit'));
$response->assertOk();
});
@ -17,14 +17,14 @@
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
->patch(route('admin.settings.profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('profile.edit'));
->assertRedirect(route('admin.settings.profile.edit'));
$user->refresh();
@ -38,14 +38,14 @@
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
->patch(route('admin.settings.profile.update'), [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('profile.edit'));
->assertRedirect(route('admin.settings.profile.edit'));
expect($user->refresh()->email_verified_at)->not->toBeNull();
});
@ -55,7 +55,7 @@
$response = $this
->actingAs($user)
->delete(route('profile.destroy'), [
->delete(route('admin.settings.profile.destroy'), [
'password' => 'password',
]);
@ -72,14 +72,14 @@
$response = $this
->actingAs($user)
->from(route('profile.edit'))
->delete(route('profile.destroy'), [
->from(route('admin.settings.profile.edit'))
->delete(route('admin.settings.profile.destroy'), [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrors('password')
->assertRedirect(route('profile.edit'));
->assertRedirect(route('admin.settings.profile.edit'));
expect($user->fresh())->not->toBeNull();
});
});

View File

@ -0,0 +1,53 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Inertia\Testing\AssertableInertia as Assert;
test('security page is displayed', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.settings.security.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('admin/settings/security')
->has('passwordRules'),
);
});
test('password can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('admin.settings.security.edit'))
->put(route('admin.settings.password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.settings.security.edit'));
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
test('correct password must be provided to update password', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('admin.settings.security.edit'))
->put(route('admin.settings.password.update'), [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrors('current_password')
->assertRedirect(route('admin.settings.security.edit'));
});

View File

@ -1,104 +0,0 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
test('security page is displayed', function () {
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
Features::passkeys([
'confirmPassword' => true,
]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertInertia(fn (Assert $page) => $page
->component('settings/security')
->where('canManagePasskeys', true)
->where('passkeys', [])
->where('canManageTwoFactor', true)
->where('twoFactorEnabled', false),
);
});
test('security page requires password confirmation when enabled', function () {
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
$user = User::factory()->create();
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$response = $this->actingAs($user)
->get(route('security.edit'));
$response->assertRedirect(route('password.confirm'));
});
test('security page renders without two factor when feature is disabled', function () {
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
config(['fortify.features' => []]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/security')
->where('canManagePasskeys', false)
->where('passkeys', [])
->where('canManageTwoFactor', false)
->missing('twoFactorEnabled')
->missing('requiresConfirmation'),
);
});
test('password can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('security.edit'));
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
test('correct password must be provided to update password', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from(route('security.edit'))
->put(route('user-password.update'), [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrors('current_password')
->assertRedirect(route('security.edit'));
});