Refactor admin settings structure and implement profile and security management
Some checks failed
tests / ci (pull_request) Has been cancelled
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:
parent
13ccb4d19c
commit
70d37741ef
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
111
app/Http/Controllers/Admin/Settings/ProfileController.php
Normal file
111
app/Http/Controllers/Admin/Settings/ProfileController.php
Normal 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(),
|
||||
]];
|
||||
}
|
||||
}
|
||||
37
app/Http/Controllers/Admin/Settings/SecurityController.php
Normal file
37
app/Http/Controllers/Admin/Settings/SecurityController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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('/');
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
@ -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(),
|
||||
},
|
||||
],
|
||||
389
resources/js/pages/admin/settings/profile.tsx
Normal file
389
resources/js/pages/admin/settings/profile.tsx
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
134
resources/js/pages/admin/settings/security.tsx
Normal file
134
resources/js/pages/admin/settings/security.tsx
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
export type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
53
tests/Feature/Admin/Settings/SecurityTest.php
Normal file
53
tests/Feature/Admin/Settings/SecurityTest.php
Normal 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'));
|
||||
});
|
||||
@ -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'));
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user