Merge pull request 'Refactor admin settings structure and implement profile and security management' (#33) from refactor/admin-setting into dev
Reviewed-on: #33
This commit is contained in:
commit
d49790ca7e
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Concerns;
|
namespace App\Concerns;
|
||||||
|
|
||||||
|
use App\Enums\Gender;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -12,6 +13,12 @@ protected function profileRules(?int $userId = null): array
|
|||||||
return [
|
return [
|
||||||
'username' => $this->usernameRules($userId),
|
'username' => $this->usernameRules($userId),
|
||||||
'email' => $this->emailRules($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
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Settings;
|
namespace App\Http\Requests\Admin\Settings;
|
||||||
|
|
||||||
use App\Concerns\PasswordValidationRules;
|
use App\Concerns\PasswordValidationRules;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Settings;
|
namespace App\Http\Requests\Admin\Settings;
|
||||||
|
|
||||||
use App\Concerns\PasswordValidationRules;
|
use App\Concerns\PasswordValidationRules;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Settings;
|
namespace App\Http\Requests\Admin\Settings;
|
||||||
|
|
||||||
use App\Concerns\ProfileValidationRules;
|
use App\Concerns\ProfileValidationRules;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
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\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use Laravel\Passkeys\PasskeyAuthenticatable;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
#[Hidden(['password'])]
|
#[Hidden(['password'])]
|
||||||
@ -19,7 +21,7 @@
|
|||||||
#[Appends(['full_name'])]
|
#[Appends(['full_name'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
use HasFactory, HasRoles, Notifiable, SoftDeletes;
|
use HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, SoftDeletes, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import { createInertiaApp } from '@inertiajs/react';
|
|||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { initializeTheme } from '@/hooks/use-appearance';
|
import { initializeTheme } from '@/hooks/use-appearance';
|
||||||
|
import SettingsLayout from '@/layouts/admin/settings/layout';
|
||||||
import AppLayout from '@/layouts/app-layout';
|
import AppLayout from '@/layouts/app-layout';
|
||||||
import AuthLayout from '@/layouts/auth-layout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
import SettingsLayout from '@/layouts/settings/layout';
|
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ createInertiaApp({
|
|||||||
return null;
|
return null;
|
||||||
case name.startsWith('auth/'):
|
case name.startsWith('auth/'):
|
||||||
return AuthLayout;
|
return AuthLayout;
|
||||||
case name.startsWith('settings/'):
|
case name.startsWith('admin/settings/'):
|
||||||
return [AppLayout, SettingsLayout];
|
return [AppLayout, SettingsLayout];
|
||||||
default:
|
default:
|
||||||
return AppLayout;
|
return AppLayout;
|
||||||
|
|||||||
@ -19,9 +19,12 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
Palette,
|
||||||
Receipt,
|
Receipt,
|
||||||
School,
|
School,
|
||||||
|
ShieldCheck,
|
||||||
User,
|
User,
|
||||||
|
UserCog,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as React from '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 tuitionInvoicesRoute } from '@/routes/admin/manage/tuition-invoices';
|
||||||
import { index as academicTerm } from '@/routes/admin/master/academic-terms';
|
import { index as academicTerm } from '@/routes/admin/master/academic-terms';
|
||||||
import { index as departmentsRoute } from '@/routes/admin/master/departments';
|
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 administratorsRoute } from '@/routes/admin/users/administrators';
|
||||||
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
||||||
import { index as studentsRoute } from '@/routes/admin/users/students';
|
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: [
|
navSecondary: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -12,9 +12,9 @@ export default function AppearanceToggleTab({
|
|||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
|
||||||
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
||||||
{ value: 'light', icon: Sun, label: 'Light' },
|
{ value: 'light', icon: Sun, label: 'Terang' },
|
||||||
{ value: 'dark', icon: Moon, label: 'Dark' },
|
{ value: 'dark', icon: Moon, label: 'Gelap' },
|
||||||
{ value: 'system', icon: Monitor, label: 'System' },
|
{ value: 'system', icon: Monitor, label: 'Sistem' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -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 {
|
import {
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator
|
DropdownMenuSeparator,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
||||||
import { logout } from '@/routes';
|
import { logout } from '@/routes';
|
||||||
import { edit } from '@/routes/profile';
|
import { edit } from '@/routes/admin/settings/profile';
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
import { Link, router } from '@inertiajs/react';
|
|
||||||
import { LogOut, Settings } from 'lucide-react';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User;
|
user: User;
|
||||||
|
|||||||
@ -5,24 +5,24 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||||
import { cn, toUrl } from '@/lib/utils';
|
import { cn, toUrl } from '@/lib/utils';
|
||||||
import { edit as editAppearance } from '@/routes/appearance';
|
import { edit as editAppearance } from '@/routes/admin/settings/appearance';
|
||||||
import { edit } from '@/routes/profile';
|
import { edit } from '@/routes/admin/settings/profile';
|
||||||
import { edit as editSecurity } from '@/routes/security';
|
import { edit as editSecurity } from '@/routes/admin/settings/security';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
const sidebarNavItems: NavItem[] = [
|
const sidebarNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Profile',
|
title: 'Profil',
|
||||||
href: edit(),
|
href: edit(),
|
||||||
icon: null,
|
icon: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Security',
|
title: 'Kata Sandi',
|
||||||
href: editSecurity(),
|
href: editSecurity(),
|
||||||
icon: null,
|
icon: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Appearance',
|
title: 'Tampilan',
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
icon: null,
|
icon: null,
|
||||||
},
|
},
|
||||||
@ -34,15 +34,15 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
|
|||||||
return (
|
return (
|
||||||
<div className="px-4 py-6">
|
<div className="px-4 py-6">
|
||||||
<Heading
|
<Heading
|
||||||
title="Settings"
|
title="Pengaturan"
|
||||||
description="Manage your profile and account settings"
|
description="Kelola profil dan pengaturan akun Anda"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:space-x-12">
|
<div className="flex flex-col lg:flex-row lg:space-x-12">
|
||||||
<aside className="w-full max-w-xl lg:w-48">
|
<aside className="w-full max-w-xl lg:w-48">
|
||||||
<nav
|
<nav
|
||||||
className="flex flex-col space-y-1 space-x-0"
|
className="flex flex-col space-y-1 space-x-0"
|
||||||
aria-label="Settings"
|
aria-label="Pengaturan"
|
||||||
>
|
>
|
||||||
{sidebarNavItems.map((item, index) => (
|
{sidebarNavItems.map((item, index) => (
|
||||||
<Button
|
<Button
|
||||||
@ -1,20 +1,20 @@
|
|||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import AppearanceTabs from '@/components/appearance-tabs';
|
import AppearanceTabs from '@/components/appearance-tabs';
|
||||||
import Heading from '@/components/heading';
|
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() {
|
export default function Appearance() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Appearance settings" />
|
<Head title="Pengaturan Tampilan" />
|
||||||
|
|
||||||
<h1 className="sr-only">Appearance settings</h1>
|
<h1 className="sr-only">Pengaturan Tampilan</h1>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Heading
|
<Heading
|
||||||
variant="small"
|
variant="small"
|
||||||
title="Appearance settings"
|
title="Pengaturan tampilan"
|
||||||
description="Update the appearance settings for your account"
|
description="Perbarui pengaturan tampilan untuk akun Anda"
|
||||||
/>
|
/>
|
||||||
<AppearanceTabs />
|
<AppearanceTabs />
|
||||||
</div>
|
</div>
|
||||||
@ -25,7 +25,7 @@ export default function Appearance() {
|
|||||||
Appearance.layout = {
|
Appearance.layout = {
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
{
|
{
|
||||||
title: 'Appearance settings',
|
title: 'Pengaturan Tampilan',
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
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 = {
|
export type User = {
|
||||||
id: number;
|
id: number;
|
||||||
|
username: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
|||||||
@ -16,6 +16,8 @@
|
|||||||
use App\Http\Controllers\Admin\Manage\TuitionPaymentController;
|
use App\Http\Controllers\Admin\Manage\TuitionPaymentController;
|
||||||
use App\Http\Controllers\Admin\Master\AcademicTermController;
|
use App\Http\Controllers\Admin\Master\AcademicTermController;
|
||||||
use App\Http\Controllers\Admin\Master\DepartmentController;
|
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\AdministratorController;
|
||||||
use App\Http\Controllers\Admin\Users\LecturerController;
|
use App\Http\Controllers\Admin\Users\LecturerController;
|
||||||
use App\Http\Controllers\Admin\Users\StudentController;
|
use App\Http\Controllers\Admin\Users\StudentController;
|
||||||
@ -81,6 +83,23 @@
|
|||||||
Route::resource('academic-advising-logs', AcademicAdvisingLogController::class)->except(['create', 'edit', 'show']);
|
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::prefix('admin/users')->name('admin.users.')->group(function () {
|
||||||
Route::resource('lecturers', LecturerController::class)->except(['show'])->parameters(['lecturers' => 'user']);
|
Route::resource('lecturers', LecturerController::class)->except(['show'])->parameters(['lecturers' => 'user']);
|
||||||
Route::patch('lecturers/{user}/reset-password', [LecturerController::class, 'resetPassword'])->name('lecturers.reset_password');
|
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']);
|
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';
|
require __DIR__.'/admin.php';
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
->get(route('profile.edit'));
|
->get(route('admin.settings.profile.edit'));
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
});
|
});
|
||||||
@ -17,14 +17,14 @@
|
|||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
->patch(route('profile.update'), [
|
->patch(route('admin.settings.profile.update'), [
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertSessionHasNoErrors()
|
->assertSessionHasNoErrors()
|
||||||
->assertRedirect(route('profile.edit'));
|
->assertRedirect(route('admin.settings.profile.edit'));
|
||||||
|
|
||||||
$user->refresh();
|
$user->refresh();
|
||||||
|
|
||||||
@ -38,14 +38,14 @@
|
|||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
->patch(route('profile.update'), [
|
->patch(route('admin.settings.profile.update'), [
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertSessionHasNoErrors()
|
->assertSessionHasNoErrors()
|
||||||
->assertRedirect(route('profile.edit'));
|
->assertRedirect(route('admin.settings.profile.edit'));
|
||||||
|
|
||||||
expect($user->refresh()->email_verified_at)->not->toBeNull();
|
expect($user->refresh()->email_verified_at)->not->toBeNull();
|
||||||
});
|
});
|
||||||
@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
->delete(route('profile.destroy'), [
|
->delete(route('admin.settings.profile.destroy'), [
|
||||||
'password' => 'password',
|
'password' => 'password',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -72,14 +72,14 @@
|
|||||||
|
|
||||||
$response = $this
|
$response = $this
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
->from(route('profile.edit'))
|
->from(route('admin.settings.profile.edit'))
|
||||||
->delete(route('profile.destroy'), [
|
->delete(route('admin.settings.profile.destroy'), [
|
||||||
'password' => 'wrong-password',
|
'password' => 'wrong-password',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertSessionHasErrors('password')
|
->assertSessionHasErrors('password')
|
||||||
->assertRedirect(route('profile.edit'));
|
->assertRedirect(route('admin.settings.profile.edit'));
|
||||||
|
|
||||||
expect($user->fresh())->not->toBeNull();
|
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