Files
biiproject-kit-v2/resources/js/Pages/Settings/Index.tsx
T

507 lines
32 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, usePage, useForm, router } from '@inertiajs/react';
import { PageProps } from '@/types';
import Swal from 'sweetalert2';
import { swal } from '@/lib/swal';
// FilePond
import { FilePond, registerPlugin } from 'react-filepond';
import 'filepond/dist/filepond.min.css';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
registerPlugin(FilePondPluginImagePreview, FilePondPluginFileValidateType);
interface SettingsProps extends PageProps {
mustVerifyEmail: boolean;
status?: string;
twoFactor: {
enabled: boolean;
qr_code: string | null;
secret: string | null;
email_enabled: boolean;
recovery_codes: string[];
};
}
/* ─── Reusable Components from System Settings ─────────────────── */
function SectionCard({ title, description, children, delay = '0s', variant = 'default' }: { title: string; description: string; children: React.ReactNode; delay?: string; variant?: 'default' | 'danger' }) {
const isDanger = variant === 'danger';
return (
<div className={`bg-white rounded-2xl border ${isDanger ? 'border-red-200 shadow-none' : 'border-gray-100 shadow-sm'} overflow-hidden h-full flex flex-col anim-up`} style={{ animationDelay: delay }}>
<div className={`px-6 py-4 border-b ${isDanger ? 'border-red-100 bg-red-50' : 'border-gray-50 bg-gray-50/30'}`}>
<h2 className={`text-sm font-bold tracking-tight ${isDanger ? 'text-red-600' : 'text-[#3D4E4B]'}`}>{title}</h2>
<p className={`text-xs font-semibold tracking-tight mt-1 ${isDanger ? 'text-red-400' : 'text-gray-400'}`}>{description}</p>
</div>
<div className="p-8 flex-1">{children}</div>
</div>
);
}
function InputField({ label, id, type = 'text', value, onChange, error, placeholder, required = false }: any) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-xs font-semibold text-gray-500 tracking-tight ml-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
<input
id={id} type={type} value={value} onChange={onChange} placeholder={placeholder}
className={`input-field${error ? ' is-error' : ''}`}
/>
{error && <p className="text-xs text-red-500 font-semibold ml-1 mt-1">{error}</p>}
</div>
);
}
const SETTINGS_TABS = ['profile', 'security', '2fa', 'danger'] as const;
type SettingsTab = typeof SETTINGS_TABS[number];
function getSettingsTabFromHash(): SettingsTab {
const hash = window.location.hash.replace('#', '') as SettingsTab;
return SETTINGS_TABS.includes(hash) ? hash : 'profile';
}
export default function SettingsIndex({ twoFactor }: SettingsProps) {
const { user } = usePage<PageProps>().props.auth;
const [activeTab, setActiveTab] = useState<SettingsTab>(getSettingsTabFromHash);
useEffect(() => {
const onHashChange = () => setActiveTab(getSettingsTabFromHash());
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
const switchTab = (tab: SettingsTab) => {
window.location.hash = tab;
setActiveTab(tab);
};
const { data: profileData, setData: setProfileData, post: postProfile, processing: profileProcessing, errors: profileErrors } = useForm({
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email || '',
phone: (user as any).phone || '',
bio: (user as any).bio || '',
avatar_file: null as File | null,
_method: 'PATCH',
});
const [avatarFiles, setAvatarFiles] = useState<any[]>([]);
const handleProfileSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
postProfile(route('profile.update'), {
preserveScroll: true,
onSuccess: () => swal.success('Saved', 'Profile updated successfully.'),
});
};
const { data: passwordData, setData: setPasswordData, put: putPassword, processing: passwordProcessing, errors: passwordErrors, reset: resetPassword } = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const handlePasswordSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
putPassword(route('password.update'), {
preserveScroll: true,
onSuccess: () => {
resetPassword();
swal.success('Saved', 'Password updated successfully.');
},
});
};
const handleDeleteAccount = async () => {
const result = await swal.confirm('Delete account?', 'This action is irreversible.', 'Delete Permanently');
if (result.isConfirmed) {
const { value: password } = await Swal.fire({
title: 'Confirm Password',
input: 'password',
inputPlaceholder: 'Enter your current password',
showCancelButton: true,
confirmButtonText: 'Delete',
confirmButtonColor: '#dc2626',
});
if (password) {
router.delete(route('profile.destroy'), {
data: { password }, preserveScroll: true,
onSuccess: () => swal.success('Deleted', 'Account removed.'),
onError: (errs) => swal.error('Error', errs.password || 'Incorrect password.'),
});
}
}
};
const initials = `${user.first_name?.charAt(0) || ''}${user.last_name?.charAt(0) || ''}`.toUpperCase();
// 2FA
const [copiedSecret, setCopiedSecret] = useState(false);
const [showCodes, setShowCodes] = useState(false);
const twoFactorForm = useForm({ code: '' });
const copySecret = () => {
navigator.clipboard.writeText(twoFactor.secret || '');
setCopiedSecret(true);
setTimeout(() => setCopiedSecret(false), 2000);
};
const handleEnable2FA = (e: React.SyntheticEvent) => {
e.preventDefault();
twoFactorForm.post(route('two-factor.enable'), {
preserveScroll: true,
onSuccess: () => { twoFactorForm.reset(); swal.success('Enabled', '2FA is now active on your account.'); },
});
};
const handleDisable2FA = async () => {
const { value: password } = await Swal.fire({
title: 'Disable 2FA',
text: 'Enter your password to confirm.',
input: 'password',
inputPlaceholder: 'Your current password',
showCancelButton: true,
confirmButtonText: 'Disable',
confirmButtonColor: '#dc2626',
});
if (password) {
router.post(route('two-factor.disable'), { password }, {
preserveScroll: true,
onSuccess: () => swal.success('Disabled', '2FA has been disabled.'),
});
}
};
const handleRegenerate = async () => {
const result = await swal.confirm('Regenerate Codes?', 'Old recovery codes will be invalidated.', 'Regenerate');
if (result.isConfirmed) {
router.post(route('two-factor.recovery-codes'), {}, {
preserveScroll: true,
onSuccess: () => { setShowCodes(true); swal.success('Regenerated', 'New recovery codes generated.'); },
});
}
};
const handleToggleEmail2FA = async (enable: boolean) => {
const { value: password } = await Swal.fire({
title: enable ? 'Enable Email 2FA' : 'Disable Email 2FA',
text: 'Enter your password to confirm.',
input: 'password',
inputPlaceholder: 'Your current password',
showCancelButton: true,
confirmButtonText: 'Confirm',
confirmButtonColor: '#3D4E4B',
});
if (password) {
router.post(route('two-factor.email.toggle'), { password, enabled: enable }, {
preserveScroll: true,
onSuccess: () => swal.success('Success', `Email Two-Factor Authentication has been ${enable ? 'enabled' : 'disabled'} successfully.`),
onError: (errs) => swal.error('Error', errs.password || 'Incorrect password.'),
});
}
};
return (
<AuthenticatedLayout>
<Head title="Account Settings" />
{/* Header Section */}
<div className="flex items-center justify-between mb-8 anim-down">
<div>
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">Account Settings</h1>
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Manage your personal credentials and security preferences</p>
</div>
</div>
{/* Tabs Row */}
<div className="flex items-center gap-1 border-b border-gray-100 w-full mb-8 anim-down" style={{ animationDelay: '0.05s' }}>
<button type="button" onClick={() => switchTab('profile')}
className={`relative pb-3 px-1 mr-8 text-sm font-bold tracking-tight transition-colors ${activeTab === 'profile' ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
Profile Information
{activeTab === 'profile' && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-[#D4A017] rounded-t-full" />}
</button>
<button type="button" onClick={() => switchTab('security')}
className={`relative pb-3 px-1 mr-8 text-sm font-bold tracking-tight transition-colors ${activeTab === 'security' ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
Security & Password
{activeTab === 'security' && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-[#D4A017] rounded-t-full" />}
</button>
<button type="button" onClick={() => switchTab('2fa')}
className={`relative pb-3 px-1 mr-8 text-sm font-bold tracking-tight transition-colors ${activeTab === '2fa' ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
Two-Factor Auth
{activeTab === '2fa' && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-[#D4A017] rounded-t-full" />}
</button>
<button type="button" onClick={() => switchTab('danger')}
className={`relative pb-3 px-1 text-sm font-bold tracking-tight transition-colors ${activeTab === 'danger' ? 'text-red-600' : 'text-gray-400 hover:text-red-600'}`}>
Danger Zone
{activeTab === 'danger' && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-red-500 rounded-t-full" />}
</button>
</div>
{/* Content Area */}
<div className="space-y-8 pb-20">
{activeTab === 'profile' && (
<div className="space-y-8 anim-fade">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<SectionCard title="Identity" description="Manage your public name and contact email" delay="0.1s">
<form onSubmit={handleProfileSubmit} className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<InputField label="First Name" id="first_name" value={profileData.first_name}
onChange={(e: any) => setProfileData('first_name', e.target.value)}
error={profileErrors.first_name} required placeholder="e.g. John" />
<InputField label="Last Name" id="last_name" value={profileData.last_name}
onChange={(e: any) => setProfileData('last_name', e.target.value)}
error={profileErrors.last_name} required placeholder="e.g. Doe" />
</div>
<InputField label="Email Address" id="email" type="email" value={profileData.email}
onChange={(e: any) => setProfileData('email', e.target.value)}
error={profileErrors.email} required placeholder="e.g. john.doe@example.com" />
<InputField label="Phone Number" id="phone" value={profileData.phone}
onChange={(e: any) => setProfileData('phone', e.target.value)}
error={(profileErrors as any).phone} placeholder="e.g. +62 812 3456 7890" />
<div className="space-y-1.5">
<label htmlFor="bio" className="block text-xs font-semibold text-gray-500 tracking-tight ml-1">Bio</label>
<textarea
id="bio"
value={profileData.bio}
onChange={(e: any) => setProfileData('bio', e.target.value)}
rows={3}
placeholder="A short description about yourself..."
className={`input-field py-3 resize-none${(profileErrors as any).bio ? ' is-error' : ''}`}
/>
{(profileErrors as any).bio && <p className="text-xs text-red-500 font-semibold ml-1 mt-1">{(profileErrors as any).bio}</p>}
</div>
<div className="flex justify-end pt-4 border-t border-gray-50">
<button type="submit" disabled={profileProcessing}
className="h-10 px-8 bg-[#3D4E4B] text-white text-sm font-bold tracking-tight rounded-xl hover:bg-[#2D3A38] transition-all shadow-lg shadow-[#3D4E4B]/20 disabled:opacity-60">
{profileProcessing ? 'Saving...' : 'Save Profile'}
</button>
</div>
</form>
</SectionCard>
</div>
<div className="lg:col-span-1">
<SectionCard title="Profile Photo" description="Update your avatar" delay="0.15s">
<div className="flex flex-col items-center gap-6">
<div className="w-24 h-24 rounded-2xl bg-[#3D4E4B] flex items-center justify-center text-white text-2xl font-bold shadow-lg shadow-[#3D4E4B]/10 overflow-hidden border-2 border-white">
{user.avatar_url ? <img src={user.avatar_url} className="w-full h-full object-cover" /> : initials}
</div>
<div className="w-full">
<FilePond files={avatarFiles} onupdatefiles={items => { setAvatarFiles(items); setProfileData('avatar_file', items[0]?.file as File || null); }}
allowMultiple={false} maxFiles={1} labelIdle='Drop Photo here' />
</div>
</div>
</SectionCard>
</div>
</div>
</div>
)}
{activeTab === 'security' && (
<div className="max-w-4xl anim-fade">
<SectionCard title="Password" description="Update your secure access key" delay="0.1s">
<form onSubmit={handlePasswordSubmit} className="space-y-8">
<div className="max-w-md">
<InputField label="Current Password" id="current_password" type="password"
value={passwordData.current_password}
onChange={(e: any) => setPasswordData('current_password', e.target.value)}
error={passwordErrors.current_password} required placeholder="••••••••" />
</div>
<div className="h-px bg-gray-50 -mx-8" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8">
<InputField label="New Password" id="password" type="password"
value={passwordData.password}
onChange={(e: any) => setPasswordData('password', e.target.value)}
error={passwordErrors.password} required placeholder="Enter new password" />
<InputField label="Confirm New Password" id="password_confirmation" type="password"
value={passwordData.password_confirmation}
onChange={(e: any) => setPasswordData('password_confirmation', e.target.value)}
error={(passwordErrors as any).password_confirmation} required placeholder="Repeat new password" />
</div>
<div className="flex justify-end pt-4 border-t border-gray-50">
<button type="submit" disabled={passwordProcessing}
className="h-10 px-8 bg-[#3D4E4B] text-white text-sm font-bold tracking-tight rounded-xl hover:bg-[#2D3A38] transition-all shadow-lg shadow-[#3D4E4B]/20 disabled:opacity-60">
{passwordProcessing ? 'Updating...' : 'Update Password'}
</button>
</div>
</form>
</SectionCard>
</div>
)}
{activeTab === '2fa' && (
<div className="max-w-3xl space-y-6 anim-fade">
<div className="p-5 bg-amber-50 border border-amber-200 rounded-2xl flex items-start gap-3">
<svg className="w-5 h-5 text-amber-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div>
<p className="text-xs font-bold text-amber-800 mb-1">Two-Factor Authentication (2FA)</p>
<p className="text-[11px] text-amber-700 font-medium leading-relaxed">
2FA menambah lapisan keamanan ekstra dengan meminta kode OTP dari aplikasi authenticator setiap kali login.
</p>
</div>
</div>
{!twoFactor.enabled ? (
<SectionCard title="Setup Authenticator App" description="Scan QR code dengan Google Authenticator, Authy, atau TOTP app lainnya">
<div className="flex flex-col md:flex-row gap-10 items-center md:items-start">
<div className="shrink-0">
<div className="p-4 bg-white border-2 border-gray-100 rounded-2xl inline-block shadow-sm">
{twoFactor.qr_code && <img src={twoFactor.qr_code} alt="2FA QR Code" className="w-44 h-44" />}
</div>
</div>
<div className="flex-1 space-y-6">
<div className="space-y-3">
{[
'Install aplikasi authenticator (Google Authenticator, Authy, 1Password)',
'Scan QR code atau masukkan manual key di bawah',
'Masukkan kode 6 digit dari aplikasi untuk mengaktifkan',
].map((step, i) => (
<div key={i} className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-[#3D4E4B] text-white text-[10px] font-black flex items-center justify-center shrink-0 mt-0.5">{i + 1}</div>
<p className="text-xs font-medium text-gray-500 leading-relaxed pt-0.5">{step}</p>
</div>
))}
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Manual Key</label>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono bg-gray-50 border border-gray-100 rounded-xl px-4 py-3 text-[#3D4E4B] tracking-wider">
{twoFactor.secret}
</code>
<button onClick={copySecret} type="button"
className="shrink-0 h-11 px-4 border border-gray-200 rounded-xl text-xs font-bold text-gray-500 hover:bg-gray-50 transition-all">
{copiedSecret ? '✓ Copied' : 'Copy'}
</button>
</div>
</div>
<form onSubmit={handleEnable2FA} className="space-y-3">
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Verification Code *</label>
<input type="text" inputMode="numeric" maxLength={6}
value={twoFactorForm.data.code}
onChange={e => twoFactorForm.setData('code', e.target.value)}
className="input-field w-full text-center tracking-[0.5em] font-bold text-lg"
placeholder="000000" />
{twoFactorForm.errors.code && <p className="text-xs text-red-500 font-semibold mt-1">{twoFactorForm.errors.code}</p>}
</div>
<button type="submit" disabled={twoFactorForm.processing || twoFactorForm.data.code.length < 6}
className="w-full h-11 bg-[#3D4E4B] text-white text-xs font-bold rounded-xl hover:bg-[#2D3A38] transition-all disabled:opacity-60">
{twoFactorForm.processing ? 'Verifying...' : 'Enable Two-Factor Authentication'}
</button>
</form>
</div>
</div>
</SectionCard>
) : (
<div className="space-y-4">
<div className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-6 flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-emerald-50 flex items-center justify-center shrink-0">
<svg className="w-6 h-6 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div className="flex-1">
<div className="text-sm font-bold text-[#3D4E4B]">Two-Factor Authentication Aktif</div>
<div className="text-xs text-gray-400 font-medium mt-0.5">Akun Anda dilindungi dengan TOTP authentication.</div>
</div>
<button onClick={handleDisable2FA} type="button"
className="h-9 px-5 text-xs font-bold text-red-500 border border-red-200 rounded-xl hover:bg-red-50 transition-all">
Disable 2FA
</button>
</div>
<SectionCard title="Recovery Codes" description="Simpan kode ini dengan aman — gunakan jika kehilangan akses ke authenticator">
<div className="space-y-4">
<div className="flex items-center gap-2">
<button onClick={() => setShowCodes(!showCodes)} type="button"
className="h-8 px-4 text-xs font-bold text-[#3D4E4B] border border-gray-200 rounded-lg hover:bg-gray-50 transition-all">
{showCodes ? 'Hide' : 'Show Codes'}
</button>
<button onClick={handleRegenerate} type="button"
className="h-8 px-4 text-xs font-bold text-[#D4A017] border border-amber-200 rounded-lg hover:bg-amber-50 transition-all">
Regenerate
</button>
</div>
{showCodes && twoFactor.recovery_codes.length > 0 && (
<div>
<div className="grid grid-cols-2 gap-2">
{twoFactor.recovery_codes.map((code, i) => (
<code key={i} className="px-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl text-xs font-mono text-[#3D4E4B] tracking-wider text-center">
{code}
</code>
))}
</div>
<p className="text-[11px] text-amber-600 font-semibold mt-3"> Setiap kode hanya bisa digunakan sekali.</p>
</div>
)}
</div>
</SectionCard>
</div>
)}
{/* Email 2FA Setup */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center shrink-0">
<svg className="w-6 h-6 text-[#D4A017]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div className="flex-1">
<div className="text-sm font-bold text-[#3D4E4B]">Two-Factor Authentication via Email</div>
<div className="text-xs text-gray-400 font-medium mt-0.5">
{twoFactor.email_enabled
? 'Email 2FA is active. Every login attempt will require an OTP code sent to your email.'
: 'Receive a 6-digit OTP code in your email to secure your login sessions.'}
</div>
</div>
{twoFactor.email_enabled ? (
<button onClick={() => handleToggleEmail2FA(false)} type="button"
className="h-9 px-5 text-xs font-bold text-red-500 border border-red-200 rounded-xl hover:bg-red-50 transition-all">
Disable Email 2FA
</button>
) : (
<button onClick={() => handleToggleEmail2FA(true)} type="button"
className="h-9 px-5 text-xs font-bold text-[#3D4E4B] border border-gray-200 rounded-xl hover:bg-gray-50 transition-all">
Enable Email 2FA
</button>
)}
</div>
</div>
)}
{activeTab === 'danger' && (
<div className="max-w-4xl anim-fade">
<SectionCard title="Account Lifecycle" description="Critical and irreversible account actions" delay="0.1s">
<div className="flex items-center justify-between gap-10 p-6 rounded-2xl bg-red-50 border border-red-100">
<div className="flex items-center gap-6">
<div className="w-14 h-14 rounded-2xl bg-white border border-red-100 flex items-center justify-center text-red-600 shrink-0">
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</div>
<div>
<p className="text-base font-bold text-red-900 leading-none">Delete Account Permanently</p>
<p className="text-xs text-red-700/60 font-semibold mt-2 leading-relaxed">
Proceeding will scrub your identity, logs, and settings from our database. This action is irreversible.
</p>
</div>
</div>
<button onClick={handleDeleteAccount}
className="h-11 px-8 bg-red-600 text-white text-xs font-bold uppercase tracking-widest rounded-xl hover:bg-red-700 transition-all shrink-0">
Delete Account
</button>
</div>
</SectionCard>
</div>
)}
</div>
</AuthenticatedLayout>
);
}