feat: inisialisasi project kit v2
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types';
|
||||
import { DataTable } from '@/Components/DataTable';
|
||||
import { Portal } from '@/Components/Portal';
|
||||
import _ from 'lodash';
|
||||
|
||||
interface Activity {
|
||||
id: number;
|
||||
log_name: string;
|
||||
description: string;
|
||||
subject_type: string;
|
||||
subject_id: number;
|
||||
causer_id: number;
|
||||
causer?: { first_name: string; last_name: string; email: string };
|
||||
properties: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ActivityLogsPageProps extends PageProps {
|
||||
activities: { data: Activity[]; meta: any; links: any[]; };
|
||||
filters: any;
|
||||
availableLogNames: string[];
|
||||
availableEvents: string[];
|
||||
}
|
||||
|
||||
/* ─── Log Detail Modal (Modern & Clean) ───────────────────────────── */
|
||||
/* ─── Log Detail Modal (Modern & Clean) ───────────────────────────── */
|
||||
function LogModal({ activity, onClose }: { activity: Activity; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const jsonString = JSON.stringify(activity.properties, null, 4);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(jsonString);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[#3D4E4B]/60 backdrop-blur-md anim-fade">
|
||||
<div className="bg-white w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden anim-zoom border border-gray-100 flex flex-col max-h-[90vh]">
|
||||
<div className="p-8 border-b border-gray-50 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xl font-bold text-[#3D4E4B] tracking-tight">Activity Details</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-50 rounded-xl transition-colors">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 font-medium">{activity.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 overflow-y-auto custom-scrollbar space-y-8">
|
||||
{/* Meta Info */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Performed By</label>
|
||||
<p className="text-sm font-bold text-[#3D4E4B]">{activity.causer ? `${activity.causer.first_name} ${activity.causer.last_name}` : 'System'}</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Date & Time</label>
|
||||
<p className="text-sm font-bold text-[#3D4E4B]">{new Date(activity.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Log Name</label>
|
||||
<p className="text-sm font-bold text-[#3D4E4B]">{activity.log_name}</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Subject</label>
|
||||
<p className="text-sm font-bold text-[#3D4E4B]">{activity.subject_type.split('\\').pop()} #{activity.subject_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payload */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Data Changes / Properties</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`text-[10px] font-bold px-2 py-0.5 rounded-lg border transition-all uppercase tracking-tight flex items-center gap-1 ${copied ? 'bg-green-500/10 text-green-500 border-green-500/20' : 'bg-[#D4A017]/5 text-[#D4A017] border-[#D4A017]/10 hover:bg-[#D4A017]/10'}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path d="M5 13l4 4L19 7" /></svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" /></svg>
|
||||
Copy JSON
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-[10px] font-bold text-gray-400 bg-gray-50 px-2 py-0.5 rounded-lg border border-gray-100 uppercase tracking-tight">JSON Format</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1E1E1E] p-6 overflow-hidden shadow-inner border border-white/5 relative group">
|
||||
<pre className="text-[11px] font-mono text-gray-300 whitespace-pre-wrap break-all leading-relaxed custom-scrollbar max-h-[400px] overflow-y-auto">
|
||||
{jsonString}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 border-t border-gray-50 bg-gray-50/30 shrink-0 flex justify-end">
|
||||
<button onClick={onClose} className="h-11 px-8 bg-white border border-gray-200 rounded-xl text-sm font-bold text-[#3D4E4B] hover:bg-gray-50 transition-all shadow-sm">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActivityLogsIndex({ activities, filters, availableLogNames, availableEvents }: ActivityLogsPageProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState<Activity | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<(number | string)[]>([]);
|
||||
const [localFilters, setLocalFilters] = useState({
|
||||
search: filters.search || '',
|
||||
log_name: filters.log_name || '',
|
||||
event: filters.event || '',
|
||||
per_page: filters.per_page || 15,
|
||||
});
|
||||
|
||||
const debouncedFilter = useCallback(_.debounce((params) => {
|
||||
setIsLoading(true);
|
||||
router.get(route('activity-logs.index'), params, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
only: ['activities', 'filters'],
|
||||
onFinish: () => setIsLoading(false)
|
||||
});
|
||||
}, 400), []);
|
||||
|
||||
const updateFilter = (key: string, value: any) => {
|
||||
const newFilters = { ...localFilters, [key]: value };
|
||||
setLocalFilters(newFilters);
|
||||
const params = { ...newFilters, page: 1 };
|
||||
setSelectedIds([]); // Clear selection on filter change
|
||||
debouncedFilter(params);
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
const count = selectedIds.length;
|
||||
swal.confirm('Purge Logs?', `Are you sure you want to permanently delete ${count} activity logs?`, 'Purge')
|
||||
.then(result => {
|
||||
if (result.isConfirmed) {
|
||||
router.post(route('activity-logs.bulk-delete'), { ids: selectedIds }, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
setSelectedIds([]);
|
||||
swal.success('Purged', `${count} logs deleted successfully.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Activity',
|
||||
accessorKey: 'description',
|
||||
cell: (a: Activity) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-[#3D4E4B] tracking-tight">{a.description}</span>
|
||||
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-0.5">{a.log_name}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Causer',
|
||||
accessorKey: 'causer',
|
||||
cell: (a: Activity) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-500">
|
||||
{a.causer ? `${a.causer.first_name[0]}${a.causer.last_name[0]}` : 'SYS'}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-[#3D4E4B]">{a.causer ? `${a.causer.first_name} ${a.causer.last_name}` : 'System'}</span>
|
||||
<span className="text-[10px] text-gray-400 font-semibold">{a.causer?.email || 'automated@system'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Properties',
|
||||
accessorKey: 'properties',
|
||||
cell: (a: Activity) => (
|
||||
<div className="max-w-[300px] truncate">
|
||||
<code className="text-[10px] bg-gray-50 px-1.5 py-0.5 rounded text-gray-500 font-semibold">
|
||||
{JSON.stringify(a.properties)}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Date & Time',
|
||||
accessorKey: 'created_at',
|
||||
cell: (a: Activity) => (
|
||||
<span className="text-xs font-semibold text-gray-400 tracking-tight">
|
||||
{new Date(a.created_at).toLocaleString('en-US', {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
accessorKey: 'actions',
|
||||
cell: (a: Activity) => (
|
||||
<div className="flex justify-end pr-4">
|
||||
<button
|
||||
onClick={() => setSelectedLog(a)}
|
||||
className="p-2 rounded-xl text-gray-400 hover:text-[#D4A017] hover:bg-[#D4A017]/5 transition-all opacity-0 group-hover:opacity-100 translate-x-2 group-hover:translate-x-0"
|
||||
title="View Details"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Activity Logs" />
|
||||
|
||||
<div className="flex items-center justify-between mb-8 anim-down">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">Activity Logs</h1>
|
||||
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Audit trail of system events and user actions</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-[240px]">
|
||||
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input type="text" placeholder="Search logs…" value={localFilters.search} onChange={e => updateFilter('search', e.target.value)}
|
||||
className="w-full h-11 pl-10 pr-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 placeholder-gray-400 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm" />
|
||||
</div>
|
||||
|
||||
<select value={localFilters.log_name} onChange={e => updateFilter('log_name', e.target.value)}
|
||||
className="h-11 px-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm cursor-pointer min-w-[140px]">
|
||||
<option value="">All Logs</option>
|
||||
{availableLogNames.map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
|
||||
<select value={localFilters.event} onChange={e => updateFilter('event', e.target.value)}
|
||||
className="h-11 px-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm cursor-pointer min-w-[140px]">
|
||||
<option value="">All Events</option>
|
||||
{availableEvents.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anim-up">
|
||||
<DataTable
|
||||
data={activities.data} columns={columns as any} meta={activities.meta} links={activities.links} filters={localFilters}
|
||||
isLoading={isLoading}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedLog && <LogModal activity={selectedLog} onClose={() => setSelectedLog(null)} />}
|
||||
|
||||
{/* Floating Bulk Actions Bar */}
|
||||
<Portal>
|
||||
<div className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-40 transition-all duration-500 ${selectedIds.length > 0 ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'}`}>
|
||||
<div className="bg-[#3D4E4B] rounded-2xl shadow-2xl px-6 py-4 flex items-center gap-6 border border-white/10 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-3 pr-6 border-r border-white/10">
|
||||
<span className="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center text-white text-xs font-bold">{selectedIds.length}</span>
|
||||
<span className="text-white text-sm font-bold tracking-tight">Logs selected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleBulkDelete} className="h-10 px-5 rounded-xl bg-red-500 text-white text-xs font-bold hover:bg-red-600 transition-all flex items-center gap-2">
|
||||
<svg className="w-4 h-4" 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>
|
||||
Bulk Purge
|
||||
</button>
|
||||
<button onClick={() => setSelectedIds([])} className="h-10 px-4 text-white/40 text-xs font-bold hover:text-white transition-colors">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import GuestLayout from '@/Layouts/GuestLayout';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
|
||||
export default function ConfirmPassword() {
|
||||
const { data, setData, post, processing, errors, reset } = useForm({ password: '' });
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('password.confirm'), { onFinish: () => reset('password') });
|
||||
};
|
||||
|
||||
return (
|
||||
<GuestLayout>
|
||||
<Head title="Confirm password" />
|
||||
|
||||
<div className="mb-8 anim-down">
|
||||
<div className="w-12 h-12 bg-[#3D4E4B]/5 rounded-2xl flex items-center justify-center mb-6">
|
||||
<svg className="w-5 h-5 text-[#3D4E4B]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-[#1A2421] tracking-tight">Confirm your password</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-400 font-medium leading-relaxed">
|
||||
For your security, please confirm your password to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="anim-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-semibold text-gray-600 mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={data.password}
|
||||
onChange={e => setData('password', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className={`auth-input${errors.password ? ' !border-red-300 !bg-red-50/50' : ''}`}
|
||||
/>
|
||||
{errors.password && <p className="mt-1.5 text-xs font-semibold text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="mt-6 w-full h-11 rounded-xl bg-[#3D4E4B] hover:bg-[#2D3A38] text-white text-sm font-bold tracking-tight transition-colors duration-200 flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin text-white/60" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Confirming…
|
||||
</>
|
||||
) : 'Confirm password'}
|
||||
</button>
|
||||
</form>
|
||||
</GuestLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import GuestLayout from '@/Layouts/GuestLayout';
|
||||
import { Head, useForm, Link } from '@inertiajs/react';
|
||||
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
const { data, setData, post, processing, errors } = useForm({ email: '' });
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('password.email'));
|
||||
};
|
||||
|
||||
return (
|
||||
<GuestLayout>
|
||||
<Head title="Forgot password" />
|
||||
|
||||
<div className="mb-8 anim-down">
|
||||
<h1 className="text-2xl font-bold text-[#1A2421] tracking-tight">Forgot password?</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-400 font-medium">
|
||||
Enter your email and we'll send a reset link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="mb-6 px-4 py-3 rounded-xl bg-emerald-50 border border-emerald-100 text-sm font-semibold text-emerald-700 anim-fade">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={submit} className="anim-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-gray-600 mb-1.5">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
value={data.email}
|
||||
onChange={e => setData('email', e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className={`auth-input${errors.email ? ' !border-red-300 !bg-red-50/50' : ''}`}
|
||||
/>
|
||||
{errors.email && <p className="mt-1.5 text-xs font-semibold text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="mt-6 w-full h-11 rounded-xl bg-[#3D4E4B] hover:bg-[#2D3A38] text-white text-sm font-bold tracking-tight transition-colors duration-200 flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin text-white/60" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Sending…
|
||||
</>
|
||||
) : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-7 text-center text-sm text-gray-400 font-medium anim-fade" style={{ animationDelay: '0.18s' }}>
|
||||
<Link href={route('login')} className="text-[#3D4E4B] font-semibold hover:text-[#D4A017] transition-colors duration-200">
|
||||
← Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</GuestLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import GuestLayout from '@/Layouts/GuestLayout';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
|
||||
interface LoginProps {
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
}
|
||||
|
||||
export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
const { system_settings } = usePage().props as any;
|
||||
const isGoogleEnabled = system_settings?.oauth_google_enabled === '1' || system_settings?.oauth_google_enabled === true;
|
||||
const isGithubEnabled = system_settings?.oauth_github_enabled === '1' || system_settings?.oauth_github_enabled === true;
|
||||
|
||||
const { data, setData, post, processing, errors, reset } = useForm({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
});
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('login'), { onFinish: () => reset('password') });
|
||||
};
|
||||
|
||||
return (
|
||||
<GuestLayout>
|
||||
<Head title="Sign in" />
|
||||
|
||||
{/* Heading */}
|
||||
<div className="mb-8 anim-down">
|
||||
<h1 className="text-2xl font-bold text-[#1A2421] tracking-tight">Sign in</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-400 font-medium">
|
||||
Enter your credentials to access the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{status && (
|
||||
<div className="mb-6 px-4 py-3 rounded-xl bg-emerald-50 border border-emerald-100 text-sm font-semibold text-emerald-700 anim-fade">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth */}
|
||||
{(isGoogleEnabled || isGithubEnabled) && (
|
||||
<div className="mb-6 anim-up" style={{ animationDelay: '0.08s' }}>
|
||||
<div className={`grid gap-3 ${isGoogleEnabled && isGithubEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{isGoogleEnabled && (
|
||||
<a
|
||||
href="/auth/google/redirect"
|
||||
className="flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-gray-300 transition-colors duration-200 text-sm font-semibold text-gray-700"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24">
|
||||
<path fill="#EA4335" d="M12 5c1.61 0 3.09.59 4.23 1.57l3.12-3.12C17.35 1.67 14.85 1 12 1 7.73 1 4.14 3.48 2.46 7.1l3.71 2.87C7.04 7.09 9.34 5 12 5z"/>
|
||||
<path fill="#4285F4" d="M23.49 12.27c0-.79-.07-1.54-.19-2.27H12v4.51h6.47c-.29 1.48-1.14 2.73-2.4 3.58l3.7 2.87c2.16-2 3.72-4.94 3.72-8.69z"/>
|
||||
<path fill="#FBBC05" d="M6.17 14.77l-3.71 2.87C4.14 21.27 7.73 23 12 23c2.97 0 5.48-1 7.37-2.69l-3.7-2.87c-1.03.69-2.35 1.11-3.67 1.11-2.66 0-4.96-2.09-5.83-4.78z"/>
|
||||
<path fill="#34A853" d="M12 19.45c1.32 0 2.64-.42 3.67-1.11l3.7 2.87C17.48 22 14.97 23 12 23 7.73 23 4.14 21.27 2.47 17.64l3.71-2.87c.86 2.69 3.16 4.68 5.82 4.68z"/>
|
||||
</svg>
|
||||
Google
|
||||
</a>
|
||||
)}
|
||||
{isGithubEnabled && (
|
||||
<a
|
||||
href="/auth/github/redirect"
|
||||
className="flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-gray-300 transition-colors duration-200 text-sm font-semibold text-gray-700"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-100" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-3 bg-white text-xs font-semibold text-gray-300 uppercase tracking-widest">or</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={submit} className="anim-up" style={{ animationDelay: '0.14s' }}>
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-gray-600 mb-1.5">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className={`auth-input${errors.email ? ' !border-red-300 !bg-red-50/50' : ''}`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1.5 text-xs font-semibold text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label htmlFor="password" className="text-sm font-semibold text-gray-600">
|
||||
Password
|
||||
</label>
|
||||
{canResetPassword && (
|
||||
<Link
|
||||
href={route('password.request')}
|
||||
className="text-xs font-semibold text-[#D4A017] hover:text-[#B88B14] transition-colors duration-200"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className={`auth-input${errors.password ? ' !border-red-300 !bg-red-50/50' : ''}`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1.5 text-xs font-semibold text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remember me */}
|
||||
<label className="mt-4 flex items-center gap-2.5 cursor-pointer select-none group w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.remember}
|
||||
onChange={(e) => setData('remember', e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-[5px] border flex items-center justify-center transition-colors duration-200 shrink-0 ${data.remember ? 'bg-[#3D4E4B] border-[#3D4E4B]' : 'bg-white border-gray-300'}`}>
|
||||
{data.remember && (
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 group-hover:text-gray-700 transition-colors duration-200">
|
||||
Keep me signed in
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="mt-6 w-full h-11 rounded-xl bg-[#3D4E4B] hover:bg-[#2D3A38] text-white text-sm font-bold tracking-tight transition-colors duration-200 flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin text-white/60" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Register link */}
|
||||
<p className="mt-7 text-center text-sm text-gray-400 font-medium anim-fade" style={{ animationDelay: '0.22s' }}>
|
||||
Don't have an account?{' '}
|
||||
<Link href={route('register')} className="text-[#3D4E4B] font-semibold hover:text-[#D4A017] transition-colors duration-200">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</GuestLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import GuestLayout from '@/Layouts/GuestLayout';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
|
||||
export default function Register() {
|
||||
const { system_settings } = usePage().props as any;
|
||||
const isRegistrationEnabled = system_settings?.allow_registration === '1' || system_settings?.allow_registration === true;
|
||||
const isGoogleEnabled = system_settings?.oauth_google_enabled === '1' || system_settings?.oauth_google_enabled === true;
|
||||
const isGithubEnabled = system_settings?.oauth_github_enabled === '1' || system_settings?.oauth_github_enabled === true;
|
||||
|
||||
const { data, setData, post, processing, errors, reset } = useForm({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('register'), { onFinish: () => reset('password', 'password_confirmation') });
|
||||
};
|
||||
|
||||
if (!isRegistrationEnabled) {
|
||||
return (
|
||||
<GuestLayout>
|
||||
<Head title="Registration Closed" />
|
||||
<div className="anim-fade">
|
||||
<div className="w-12 h-12 bg-amber-50 border border-amber-100 rounded-2xl flex items-center justify-center mb-6">
|
||||
<svg className="w-5 h-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-[#1A2421] tracking-tight">Registration closed</h1>
|
||||
<p className="mt-2 text-sm text-gray-400 font-medium">New account registration is currently disabled by the administrator.</p>
|
||||
<div className="mt-8 pt-6 border-t border-gray-100">
|
||||
<Link href={route('login')} className="text-sm font-semibold text-[#3D4E4B] hover:text-[#D4A017] transition-colors duration-200">
|
||||
← Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</GuestLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GuestLayout>
|
||||
<Head title="Create account" />
|
||||
|
||||
{/* Heading */}
|
||||
<div className="mb-8 anim-down">
|
||||
<h1 className="text-2xl font-bold text-[#1A2421] tracking-tight">Create account</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-400 font-medium">Fill in your details to get started.</p>
|
||||
</div>
|
||||
|
||||
{/* OAuth */}
|
||||
{(isGoogleEnabled || isGithubEnabled) && (
|
||||
<div className="mb-6 anim-up" style={{ animationDelay: '0.08s' }}>
|
||||
<div className={`grid gap-3 ${isGoogleEnabled && isGithubEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{isGoogleEnabled && (
|
||||
<a href="/auth/google/redirect" className="flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-gray-300 transition-colors duration-200 text-sm font-semibold text-gray-700">
|
||||
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24">
|
||||
<path fill="#EA4335" d="M12 5c1.61 0 3.09.59 4.23 1.57l3.12-3.12C17.35 1.67 14.85 1 12 1 7.73 1 4.14 3.48 2.46 7.1l3.71 2.87C7.04 7.09 9.34 5 12 5z"/>
|
||||
<path fill="#4285F4" d="M23.49 12.27c0-.79-.07-1.54-.19-2.27H12v4.51h6.47c-.29 1.48-1.14 2.73-2.4 3.58l3.7 2.87c2.16-2 3.72-4.94 3.72-8.69z"/>
|
||||
<path fill="#FBBC05" d="M6.17 14.77l-3.71 2.87C4.14 21.27 7.73 23 12 23c2.97 0 5.48-1 7.37-2.69l-3.7-2.87c-1.03.69-2.35 1.11-3.67 1.11-2.66 0-4.96-2.09-5.83-4.78z"/>
|
||||
<path fill="#34A853" d="M12 19.45c1.32 0 2.64-.42 3.67-1.11l3.7 2.87C17.48 22 14.97 23 12 23 7.73 23 4.14 21.27 2.47 17.64l3.71-2.87c.86 2.69 3.16 4.68 5.82 4.68z"/>
|
||||
</svg>
|
||||
Google
|
||||
</a>
|
||||
)}
|
||||
{isGithubEnabled && (
|
||||
<a href="/auth/github/redirect" className="flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-gray-300 transition-colors duration-200 text-sm font-semibold text-gray-700">
|
||||
<svg className="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-100" /></div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-3 bg-white text-xs font-semibold text-gray-300 uppercase tracking-widest">or</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={submit} className="anim-up" style={{ animationDelay: '0.14s' }}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="first_name" className="block text-sm font-semibold text-gray-600 mb-1.5">First name</label>
|
||||
<input id="first_name" type="text" autoComplete="given-name" autoFocus value={data.first_name}
|
||||
onChange={e => setData('first_name', e.target.value)}
|
||||
placeholder="Alex"
|
||||
className={`auth-input${errors.first_name ? ' !border-red-300 !bg-red-50/50' : ''}`} />
|
||||
{errors.first_name && <p className="mt-1.5 text-xs font-semibold text-red-500">{errors.first_name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="last_name" className="block text-sm font-semibold text-gray-600 mb-1.5">Last name</label>
|
||||
<input id="last_name" type="text" autoComplete="family-name" value={data.last_name}
|
||||
onChange={e => setData('last_name', e.target.value)}
|
||||
placeholder="Johnson"
|
||||
className={`auth-input${errors.last_name ? ' !border-red-300 !bg-red-50/50' : ''}`} />
|
||||
{errors.last_name && <p className="mt-1.5 text-xs font-semibold text-red-500">{errors.last_name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-gray-600 mb-1.5">Email address</label>
|
||||
<input id="email" type="email" autoComplete="email" value={data.email}
|
||||
onChange={e => setData('email', e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className={`auth-input${errors.email ? ' !border-red-300 !bg-red-50/50' : ''}`} />
|
||||
{errors.email && <p className="mt-1.5 text-xs font-semibold text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-semibold text-gray-600 mb-1.5">Password</label>
|
||||
<input id="password" type="password" autoComplete="new-password" value={data.password}
|
||||
onChange={e => setData('password', e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
className={`auth-input${errors.password ? ' !border-red-300 !bg-red-50/50' : ''}`} />
|
||||
{errors.password && <p className="mt-1.5 text-xs font-semibold text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password_confirmation" className="block text-sm font-semibold text-gray-600 mb-1.5">Confirm password</label>
|
||||
<input id="password_confirmation" type="password" autoComplete="new-password" value={data.password_confirmation}
|
||||
onChange={e => setData('password_confirmation', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className={`auth-input${errors.password_confirmation ? ' !border-red-300 !bg-red-50/50' : ''}`} />
|
||||
{errors.password_confirmation && <p className="mt-1.5 text-xs font-semibold text-red-500">{errors.password_confirmation}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={processing}
|
||||
className="mt-6 w-full h-11 rounded-xl bg-[#3D4E4B] hover:bg-[#2D3A38] text-white text-sm font-bold tracking-tight transition-colors duration-200 flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
{processing ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin text-white/60" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Creating account…
|
||||
</>
|
||||
) : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-7 text-center text-sm text-gray-400 font-medium anim-fade" style={{ animationDelay: '0.22s' }}>
|
||||
Already have an account?{' '}
|
||||
<Link href={route('login')} className="text-[#3D4E4B] font-semibold hover:text-[#D4A017] transition-colors duration-200">Sign in</Link>
|
||||
</p>
|
||||
</GuestLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import GuestLayout from '@/Layouts/GuestLayout';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
|
||||
interface ResetPasswordProps {
|
||||
token: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default function ResetPassword({ token, email }: ResetPasswordProps) {
|
||||
const { data, setData, post, processing, errors, reset } = useForm({
|
||||
token,
|
||||
email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('password.store'), { onFinish: () => reset('password', 'password_confirmation') });
|
||||
};
|
||||
|
||||
return (
|
||||
<GuestLayout>
|
||||
<Head title="Reset password" />
|
||||
|
||||
<div className="mb-8 anim-down">
|
||||
<h1 className="text-2xl font-bold text-[#1A2421] tracking-tight">Set new password</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-400 font-medium">
|
||||
Choose a strong password for <span className="text-[#3D4E4B] font-semibold">{email}</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4 anim-up" style={{ animationDelay: '0.1s' }}>
|
||||
{/* Email readonly — needed for form submission, not shown */}
|
||||
<input type="hidden" value={data.email} />
|
||||
<input type="hidden" value={data.token} />
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-semibold text-gray-600 mb-1.5">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
value={data.password}
|
||||
onChange={e => setData('password', e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
className={`auth-input${errors.password ? ' !border-red-300 !bg-red-50/50' : ''}`}
|
||||
/>
|
||||
{errors.password && <p className="mt-1.5 text-xs font-semibold text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password_confirmation" className="block text-sm font-semibold text-gray-600 mb-1.5">
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={data.password_confirmation}
|
||||
onChange={e => setData('password_confirmation', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className={`auth-input${errors.password_confirmation ? ' !border-red-300 !bg-red-50/50' : ''}`}
|
||||
/>
|
||||
{errors.password_confirmation && <p className="mt-1.5 text-xs font-semibold text-red-500">{errors.password_confirmation}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="mt-2 w-full h-11 rounded-xl bg-[#3D4E4B] hover:bg-[#2D3A38] text-white text-sm font-bold tracking-tight transition-colors duration-200 flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin text-white/60" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Saving…
|
||||
</>
|
||||
) : 'Reset password'}
|
||||
</button>
|
||||
</form>
|
||||
</GuestLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import GuestLayout from '@/Layouts/GuestLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
|
||||
export default function VerifyEmail({ status }: { status?: string }) {
|
||||
const { post, processing } = useForm({});
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('verification.send'));
|
||||
};
|
||||
|
||||
return (
|
||||
<GuestLayout>
|
||||
<Head title="Verify email" />
|
||||
|
||||
<div className="mb-8 anim-down">
|
||||
<div className="w-12 h-12 bg-[#3D4E4B]/5 rounded-2xl flex items-center justify-center mb-6">
|
||||
<svg className="w-5 h-5 text-[#3D4E4B]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-[#1A2421] tracking-tight">Check your email</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-400 font-medium leading-relaxed">
|
||||
We sent a verification link to your email address. Click the link to activate your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status === 'verification-link-sent' && (
|
||||
<div className="mb-6 px-4 py-3 rounded-xl bg-emerald-50 border border-emerald-100 text-sm font-semibold text-emerald-700 anim-fade">
|
||||
A new verification link has been sent to your email.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={submit} className="anim-up" style={{ animationDelay: '0.1s' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="w-full h-11 rounded-xl bg-[#3D4E4B] hover:bg-[#2D3A38] text-white text-sm font-bold tracking-tight transition-colors duration-200 flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin text-white/60" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Sending…
|
||||
</>
|
||||
) : 'Resend verification email'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 text-center anim-fade" style={{ animationDelay: '0.18s' }}>
|
||||
<Link
|
||||
href={route('logout')}
|
||||
method="post"
|
||||
as="button"
|
||||
className="text-sm font-semibold text-gray-400 hover:text-[#3D4E4B] transition-colors duration-200"
|
||||
>
|
||||
Sign out
|
||||
</Link>
|
||||
</div>
|
||||
</GuestLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import React from 'react';
|
||||
import { PageProps } from '@/types';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
ArcElement,
|
||||
} from 'chart.js';
|
||||
import { Line, Bar, Doughnut } from 'react-chartjs-2';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface RecentUser {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
avatar_url?: string;
|
||||
created_at: string;
|
||||
roles: { name: string }[];
|
||||
}
|
||||
|
||||
interface ChartItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalRoles: number;
|
||||
recentUsers: RecentUser[];
|
||||
charts: {
|
||||
userGrowth: ChartItem[];
|
||||
activityStats: ChartItem[];
|
||||
};
|
||||
}
|
||||
|
||||
interface DashboardProps extends PageProps {
|
||||
stats: DashboardStats;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, icon, variant = 'white', delay = '0s' }: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub: string;
|
||||
icon: React.ReactNode;
|
||||
variant?: 'white' | 'dark' | 'gold' | 'teal';
|
||||
delay?: string;
|
||||
}) {
|
||||
const styles = {
|
||||
white: 'bg-white text-[#3D4E4B] border border-gray-100',
|
||||
dark: 'bg-[#3D4E4B] text-white border border-[#3D4E4B]',
|
||||
gold: 'bg-[#D4A017] text-white border border-[#D4A017]',
|
||||
teal: 'bg-[#21A59F] text-white border border-[#21A59F]',
|
||||
};
|
||||
return (
|
||||
<div className={`relative rounded-2xl p-6 anim-up shadow-sm hover:-translate-y-0.5 transition-transform duration-200 ${styles[variant]}`} style={{ animationDelay: delay }}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold opacity-60">{label}</p>
|
||||
<p className="text-3xl font-bold tracking-tighter mt-1">{typeof value === 'number' ? value.toLocaleString() : value}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-xl bg-black/5 border border-white/10 flex items-center justify-center shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold opacity-60">{sub}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard({ stats }: DashboardProps) {
|
||||
const { totalUsers, activeUsers, totalRoles, recentUsers, charts } = stats;
|
||||
const inactiveUsers = totalUsers - activeUsers;
|
||||
|
||||
// User Growth Chart Configuration
|
||||
const growthData = {
|
||||
labels: charts.userGrowth.map(d => d.label),
|
||||
datasets: [
|
||||
{
|
||||
label: 'New Registrations',
|
||||
data: charts.userGrowth.map(d => d.value),
|
||||
borderColor: '#D4A017',
|
||||
backgroundColor: 'rgba(212, 160, 23, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#D4A017',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Activity Bar Chart Configuration
|
||||
const activityData = {
|
||||
labels: charts.activityStats.map(d => d.label),
|
||||
datasets: [
|
||||
{
|
||||
label: 'System Activity',
|
||||
data: charts.activityStats.map(d => d.value),
|
||||
backgroundColor: '#3D4E4B',
|
||||
borderRadius: 8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#3D4E4B',
|
||||
titleFont: { size: 12, weight: 'bold' as const },
|
||||
padding: 12,
|
||||
cornerRadius: 12,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { font: { size: 10, weight: 'bold' as const }, color: '#9ca3af' } },
|
||||
y: { border: { display: false }, ticks: { font: { size: 10, weight: 'bold' as const }, color: '#9ca3af' }, grid: { color: '#f3f4f6' } },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total Users" value={totalUsers} sub="All registered accounts"
|
||||
variant="gold" delay="0s"
|
||||
icon={<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Users" value={activeUsers} sub={`${totalUsers > 0 ? Math.round((activeUsers / totalUsers) * 100) : 0}% of total`}
|
||||
variant="dark" delay="0.08s"
|
||||
icon={<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>}
|
||||
/>
|
||||
<StatCard
|
||||
label="Inactive Users" value={inactiveUsers} sub="Suspended or deactivated"
|
||||
variant="teal" delay="0.16s"
|
||||
icon={<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /></svg>}
|
||||
/>
|
||||
<StatCard
|
||||
label="Roles" value={totalRoles} sub="Defined permission sets"
|
||||
variant="white" delay="0.24s"
|
||||
icon={<svg className="w-5 h-5" 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="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Growth Chart */}
|
||||
<div className="lg:col-span-8 bg-white rounded-2xl border border-gray-100 shadow-sm p-8 anim-up" style={{ animationDelay: '0.32s' }}>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 className="text-base font-black text-[#3D4E4B] tracking-tight leading-none">User Growth</h3>
|
||||
<p className="text-xs font-bold text-gray-400 mt-2 tracking-tight">Registration metrics over the last 6 months</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#D4A017]" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-[#D4A017]">Accounts</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[300px]">
|
||||
<Line data={growthData} options={chartOptions as any} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown Chart */}
|
||||
<div className="lg:col-span-4 bg-[#3D4E4B] rounded-2xl p-8 text-white anim-up shadow-lg shadow-[#3D4E4B]/20" style={{ animationDelay: '0.4s' }}>
|
||||
<h3 className="text-base font-black tracking-tight leading-none mb-2">Account Integrity</h3>
|
||||
<p className="text-xs font-bold text-white/40 tracking-tight mb-8 uppercase tracking-widest">Global Status Registry</p>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-48 h-48 mb-8">
|
||||
<Doughnut
|
||||
data={{
|
||||
labels: ['Active', 'Inactive'],
|
||||
datasets: [{
|
||||
data: [activeUsers, inactiveUsers],
|
||||
backgroundColor: ['#D4A017', 'rgba(255,255,255,0.1)'],
|
||||
borderWidth: 0,
|
||||
cutout: '75%',
|
||||
}]
|
||||
}}
|
||||
options={{
|
||||
plugins: { legend: { display: false } },
|
||||
maintainAspectRatio: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-[#D4A017]" />
|
||||
<span className="text-xs font-bold opacity-60">Verified Active</span>
|
||||
</div>
|
||||
<span className="text-sm font-black">{activeUsers}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-white/20" />
|
||||
<span className="text-xs font-bold opacity-60">Restricted/Inactive</span>
|
||||
</div>
|
||||
<span className="text-sm font-black">{inactiveUsers}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Activity Logs Bar Chart */}
|
||||
<div className="lg:col-span-4 bg-white rounded-2xl border border-gray-100 shadow-sm p-8 anim-up" style={{ animationDelay: '0.48s' }}>
|
||||
<h3 className="text-base font-black text-[#3D4E4B] tracking-tight leading-none mb-8">System Pulse</h3>
|
||||
<div className="h-[250px]">
|
||||
<Bar data={activityData} options={chartOptions as any} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Users Table */}
|
||||
<div className="lg:col-span-8 bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden anim-up" style={{ animationDelay: '0.56s' }}>
|
||||
<div className="flex items-center justify-between px-8 py-6 border-b border-gray-50">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-[#3D4E4B] tracking-tight uppercase tracking-widest">Recent Registry</h3>
|
||||
</div>
|
||||
<Link href="/users" className="text-[10px] font-black uppercase tracking-widest text-[#D4A017] hover:text-[#B88B14] transition-colors">
|
||||
View Full Archive →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentUsers.map(u => {
|
||||
const initials = `${u.first_name?.charAt(0) ?? ''}${u.last_name?.charAt(0) ?? ''}`.toUpperCase();
|
||||
return (
|
||||
<div key={u.id} className="flex items-center justify-between px-8 py-5 hover:bg-gray-50/30 transition-colors group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-2xl bg-[#3D4E4B] text-white text-[10px] font-black flex items-center justify-center border-2 border-white shadow-lg shadow-[#3D4E4B]/10 overflow-hidden">
|
||||
{u.avatar_url ? <img src={u.avatar_url} className="w-full h-full object-cover" alt="" /> : initials}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black text-[#3D4E4B] tracking-tight">{u.first_name} {u.last_name}</p>
|
||||
<p className="text-[10px] font-bold text-gray-300 uppercase tracking-widest mt-1">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className={`px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border ${u.status === 'active' ? 'text-emerald-600 border-emerald-100 bg-emerald-50/50' : 'text-gray-400 border-gray-100 bg-gray-50/50'}`}>
|
||||
{u.status}
|
||||
</span>
|
||||
<Link href={route('users.show', u.id)} className="w-8 h-8 rounded-xl bg-gray-50 flex items-center justify-center text-gray-400 hover:bg-[#3D4E4B] hover:text-white transition-all">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M9 5l7 7-7 7" /></svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
const NAV = [
|
||||
{ id: 'intro', label: 'Pendahuluan', icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
{ id: 'quickstart', label: 'Quick Start', icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> },
|
||||
{ id: 'stack', label: 'Tech Stack', icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> },
|
||||
{ id: 'auth', label: 'Autentikasi', icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> },
|
||||
{ id: 'roles', label: 'Roles & Permission', icon: <svg className="w-4 h-4" 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> },
|
||||
{ id: 'features', label: 'Fitur Lengkap', icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h7" /></svg> },
|
||||
{ id: 'api', label: 'REST API', icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg> },
|
||||
{ id: '2fa', label: 'Two-Factor Auth', icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> },
|
||||
{ id: 'settings', label: 'System Settings', icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
|
||||
{ id: 'structure', label: 'Struktur Folder', icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg> },
|
||||
];
|
||||
|
||||
function Badge({ children, color = 'gray' }: { children: React.ReactNode; color?: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
green: 'bg-emerald-50 text-emerald-700 border border-emerald-200',
|
||||
blue: 'bg-blue-50 text-blue-700 border border-blue-200',
|
||||
amber: 'bg-amber-50 text-amber-700 border border-amber-200',
|
||||
red: 'bg-red-50 text-red-700 border border-red-200',
|
||||
gray: 'bg-gray-50 text-gray-600 border border-gray-200',
|
||||
purple: 'bg-purple-50 text-purple-700 border border-purple-200',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-lg text-[10px] font-bold uppercase tracking-widest ${colors[color]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ children, lang = 'bash' }: { children: string; lang?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
return (
|
||||
<div className="relative group mt-3">
|
||||
<div className="flex items-center justify-between bg-[#1E2A28] rounded-t-xl px-4 py-2 border-b border-white/5">
|
||||
<span className="text-[10px] text-gray-400 font-mono font-bold uppercase tracking-widest">{lang}</span>
|
||||
<button onClick={copy} className="text-[10px] text-gray-400 font-bold hover:text-white transition-colors">
|
||||
{copied ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="bg-[#152320] text-emerald-300 text-xs font-mono p-4 rounded-b-xl overflow-x-auto leading-relaxed">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ id, title, badge, badgeColor }: { id: string; title: string; badge?: string; badgeColor?: string }) {
|
||||
return (
|
||||
<div id={id} className="flex items-center gap-3 mb-6 pt-2 scroll-mt-6">
|
||||
<h2 className="text-base font-black text-[#3D4E4B] dark:text-white tracking-tight">{title}</h2>
|
||||
{badge && <Badge color={badgeColor}>{badge}</Badge>}
|
||||
<div className="flex-1 h-px bg-gray-100 dark:bg-white/10"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Endpoint({ method, path, desc, auth = true }: { method: string; path: string; desc: string; auth?: boolean }) {
|
||||
const methodColor: Record<string, string> = {
|
||||
GET: 'bg-blue-100 text-blue-700',
|
||||
POST: 'bg-emerald-100 text-emerald-700',
|
||||
PATCH: 'bg-amber-100 text-amber-700',
|
||||
DELETE: 'bg-red-100 text-red-700',
|
||||
PUT: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-3 border-b border-gray-50 last:border-0">
|
||||
<span className={`shrink-0 px-2.5 py-1 rounded-lg text-[10px] font-black tracking-widest uppercase ${methodColor[method] ?? 'bg-gray-100 text-gray-600'}`}>{method}</span>
|
||||
<code className="flex-1 text-xs font-mono text-[#3D4E4B] font-bold">{path}</code>
|
||||
<span className="text-xs text-gray-400 font-medium hidden md:block">{desc}</span>
|
||||
{auth && <span className="shrink-0 text-[10px] text-amber-600 font-bold">🔒 Auth</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocsIndex() {
|
||||
const [activeSection, setActiveSection] = useState('intro');
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ids = NAV.map(n => n.id);
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter(e => e.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
if (visible.length > 0) setActiveSection(visible[0].target.id);
|
||||
},
|
||||
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 }
|
||||
);
|
||||
ids.forEach(id => { const el = document.getElementById(id); if (el) observer.observe(el); });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const scrollTo = (id: string) => {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Dokumentasi biiproject kit v2" />
|
||||
|
||||
<div className="flex items-center justify-between mb-8 anim-down">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[#3D4E4B] dark:text-white tracking-tight leading-none">Dokumentasi</h1>
|
||||
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Panduan lengkap biiproject kit v2</p>
|
||||
</div>
|
||||
<Badge color="green">v2.0</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8 anim-up">
|
||||
{/* Sticky Sidebar Nav */}
|
||||
<aside className="hidden lg:block w-52 shrink-0">
|
||||
<div className="sticky top-6 bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-3 space-y-0.5">
|
||||
{NAV.map(s => (
|
||||
<button key={s.id} onClick={() => scrollTo(s.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-xs font-bold text-left transition-all ${activeSection === s.id ? 'bg-[#3D4E4B] text-white' : 'text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-[#3D4E4B] dark:hover:text-white'}`}>
|
||||
<span className={`shrink-0 ${activeSection === s.id ? 'text-[#D4A017]' : ''}`}>{s.icon}</span>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<div ref={contentRef} className="flex-1 min-w-0 space-y-12 pb-20">
|
||||
|
||||
{/* ── PENDAHULUAN ── */}
|
||||
<section>
|
||||
<SectionHeader id="intro" title="Pendahuluan" badge="Starter Kit" badgeColor="blue" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8 space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium leading-relaxed">
|
||||
<strong className="text-[#3D4E4B] dark:text-white">biiproject kit v2</strong> adalah starter kit enterprise berbasis <strong>Laravel 13 + React (Inertia.js)</strong> yang dirancang untuk mempercepat pembangunan aplikasi web dengan fitur manajemen pengguna, hak akses berbasis peran, monitoring aktivitas, notifikasi, dan konfigurasi sistem yang lengkap.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-2">
|
||||
{[
|
||||
{ label: 'Users & Roles', icon: '👥' },
|
||||
{ label: 'Activity Logs', icon: '📋' },
|
||||
{ label: 'System Settings', icon: '⚙️' },
|
||||
{ label: 'Two-Factor Auth', icon: '🔒' },
|
||||
{ label: 'REST API v1', icon: '🔌' },
|
||||
{ label: 'Dark Mode', icon: '🌙' },
|
||||
{ label: 'Notifikasi', icon: '🔔' },
|
||||
{ label: 'Mobile Ready', icon: '📱' },
|
||||
].map(f => (
|
||||
<div key={f.label} className="p-4 bg-gray-50 dark:bg-white/5 rounded-xl text-center">
|
||||
<div className="text-2xl mb-1">{f.icon}</div>
|
||||
<div className="text-[11px] font-bold text-[#3D4E4B] dark:text-white">{f.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── QUICK START ── */}
|
||||
<section>
|
||||
<SectionHeader id="quickstart" title="Quick Start" badge="Setup" badgeColor="green" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-2">1. Clone & Install</h3>
|
||||
<CodeBlock lang="bash">{`git clone https://github.com/your-org/biiskit.git
|
||||
cd biiskit
|
||||
composer install
|
||||
npm install`}</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-2">2. Konfigurasi Environment</h3>
|
||||
<CodeBlock lang="bash">{`cp .env.example .env
|
||||
php artisan key:generate
|
||||
|
||||
# Edit .env sesuai konfigurasi database
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=biiskit
|
||||
DB_USERNAME=your_user
|
||||
DB_PASSWORD=your_password`}</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-2">3. Migrasi & Seeder</h3>
|
||||
<CodeBlock lang="bash">{`php artisan migrate --seed`}</CodeBlock>
|
||||
<p className="text-xs text-gray-400 font-medium mt-2">Seeder akan membuat 3 akun default: <code className="bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded font-mono">superadmin</code>, <code className="bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded font-mono">admin</code>, dan <code className="bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded font-mono">user</code>.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-2">4. Jalankan Server</h3>
|
||||
<CodeBlock lang="bash">{`# Terminal 1 — Laravel
|
||||
php artisan serve
|
||||
|
||||
# Terminal 2 — Vite dev server
|
||||
npm run dev`}</CodeBlock>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/40 rounded-xl">
|
||||
<p className="text-xs font-bold text-amber-700 dark:text-amber-400">Akses aplikasi di <code className="font-mono">http://localhost:8000</code> — redirect otomatis ke halaman login.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── TECH STACK ── */}
|
||||
<section>
|
||||
<SectionHeader id="stack" title="Tech Stack" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ layer: 'Backend', items: ['Laravel 13', 'PHP 8.3', 'PostgreSQL', 'Laravel Sanctum', 'Spatie Permission', 'Spatie Activity Log'] },
|
||||
{ layer: 'Frontend', items: ['React 18', 'Inertia.js v2', 'TypeScript', 'Tailwind CSS v4', 'Vite 6', 'Chart.js'] },
|
||||
{ layer: 'Keamanan', items: ['RBAC (Role-Based Access Control)', 'Two-Factor Auth (TOTP)', 'Sanctum API Tokens', 'Gate::before super-admin bypass', 'Bcrypt Password Hashing'] },
|
||||
{ layer: 'DevOps', items: ['PostgreSQL + Redis (Docker ready)', 'Laravel Queue (database)', 'Cache: database driver', 'Pest PHP testing suite'] },
|
||||
].map(s => (
|
||||
<div key={s.layer} className="p-5 bg-gray-50 dark:bg-white/5 rounded-xl">
|
||||
<div className="text-xs font-black text-[#D4A017] uppercase tracking-widest mb-3">{s.layer}</div>
|
||||
<div className="space-y-1.5">
|
||||
{s.items.map(i => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#3D4E4B] dark:bg-[#D4A017] shrink-0"></span>
|
||||
{i}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── AUTENTIKASI ── */}
|
||||
<section>
|
||||
<SectionHeader id="auth" title="Autentikasi" badge="Web + API" badgeColor="purple" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-4">Akun Bawaan (Seeder)</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-white/10">
|
||||
<th className="text-left font-black text-gray-400 uppercase tracking-widest py-2 pr-6">Email</th>
|
||||
<th className="text-left font-black text-gray-400 uppercase tracking-widest py-2 pr-6">Password</th>
|
||||
<th className="text-left font-black text-gray-400 uppercase tracking-widest py-2">Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ email: 'superadmin@biiskit.com', pw: 'password', role: 'super-admin', color: 'red' },
|
||||
{ email: 'admin@biiskit.com', pw: 'password', role: 'admin', color: 'amber' },
|
||||
{ email: 'user@biiskit.com', pw: 'password', role: 'user', color: 'blue' },
|
||||
].map(u => (
|
||||
<tr key={u.email} className="border-b border-gray-50 dark:border-white/5 last:border-0">
|
||||
<td className="py-3 pr-6 font-mono text-[#3D4E4B] dark:text-white font-bold">{u.email}</td>
|
||||
<td className="py-3 pr-6 font-mono text-gray-400">{u.pw}</td>
|
||||
<td className="py-3"><Badge color={u.color}>{u.role}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-3">Alur Login Web</h3>
|
||||
<div className="flex flex-wrap gap-2 items-center text-xs font-bold text-gray-500">
|
||||
{['Form Login', '→', 'Auth Check', '→', '2FA Challenge?', '→', 'Email Verified?', '→', 'Dashboard'].map((s, i) => (
|
||||
<span key={i} className={s === '→' ? 'text-gray-300' : 'px-3 py-1.5 bg-gray-50 dark:bg-white/5 rounded-lg text-[#3D4E4B] dark:text-white'}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 font-medium mt-2">2FA Challenge hanya muncul jika user telah mengaktifkan Two-Factor Auth di <code className="bg-gray-100 dark:bg-white/10 px-1 py-0.5 rounded font-mono">/settings#2fa</code>.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-2">Login via API</h3>
|
||||
<CodeBlock lang="json">{`POST /api/v1/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "admin@biiskit.com",
|
||||
"password": "password"
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
"token": "1|abc123...",
|
||||
"user": { "id": 1, "email": "admin@biiskit.com", ... }
|
||||
}`}</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── ROLES & PERMISSIONS ── */}
|
||||
<section>
|
||||
<SectionHeader id="roles" title="Roles & Permission" badge="Spatie" badgeColor="green" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8 space-y-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium leading-relaxed">
|
||||
Menggunakan <strong className="text-[#3D4E4B] dark:text-white">spatie/laravel-permission</strong>. Role <Badge color="red">super-admin</Badge> mendapat akses penuh via <code className="text-[11px] bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded font-mono">Gate::before</code> bypass — tidak perlu assign permission satu per satu.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-white/10">
|
||||
<th className="text-left font-black text-gray-400 uppercase tracking-widest py-2 pr-6">Permission</th>
|
||||
<th className="text-center font-black text-gray-400 uppercase tracking-widest py-2 px-4">user</th>
|
||||
<th className="text-center font-black text-gray-400 uppercase tracking-widest py-2 px-4">admin</th>
|
||||
<th className="text-center font-black text-gray-400 uppercase tracking-widest py-2 px-4">super-admin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ perm: 'user.view', u: true, a: true, s: true },
|
||||
{ perm: 'user.create', u: false, a: true, s: true },
|
||||
{ perm: 'user.edit', u: false, a: true, s: true },
|
||||
{ perm: 'user.delete', u: false, a: true, s: true },
|
||||
{ perm: 'role.view', u: false, a: true, s: true },
|
||||
{ perm: 'role.manage', u: false, a: false, s: true },
|
||||
{ perm: 'settings.manage',u: false, a: false, s: true },
|
||||
{ perm: 'reports.view', u: false, a: true, s: true },
|
||||
].map(row => (
|
||||
<tr key={row.perm} className="border-b border-gray-50 dark:border-white/5 last:border-0">
|
||||
<td className="py-3 pr-6 font-mono font-bold text-[#3D4E4B] dark:text-white">{row.perm}</td>
|
||||
{[row.u, row.a, row.s].map((v, i) => (
|
||||
<td key={i} className="py-3 text-center px-4">
|
||||
{v ? <span className="text-emerald-500 font-black text-sm">✓</span> : <span className="text-gray-200 font-black text-sm">✗</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-2">Pengecekan Permission di Controller</h3>
|
||||
<CodeBlock lang="php">{`// Via Policy (model instance)
|
||||
$this->authorize('update', $user);
|
||||
|
||||
// Via Gate string (tanpa model)
|
||||
$this->authorize('user.delete');
|
||||
|
||||
// Via Blade / React (shared props)
|
||||
// auth.permissions = ['user.view', 'user.create', ...]`}</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FITUR LENGKAP ── */}
|
||||
<section>
|
||||
<SectionHeader id="features" title="Fitur Lengkap" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8 space-y-6">
|
||||
{[
|
||||
{
|
||||
title: 'Manajemen Pengguna',
|
||||
icon: '👥',
|
||||
items: [
|
||||
'CRUD lengkap (tambah, edit, hapus, restore)',
|
||||
'Soft delete dengan arsip & purge permanen',
|
||||
'Bulk archive / restore / force-delete',
|
||||
'Filter by status, role, pencarian nama/email',
|
||||
'Sorting multi-kolom + pagination',
|
||||
'Export ke Excel (.xlsx)',
|
||||
'Import user massal via Excel/CSV',
|
||||
'Assign multi-role per user',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Roles & Permission Manager',
|
||||
icon: '🛡️',
|
||||
items: [
|
||||
'Kelola role dari UI (tambah / hapus role)',
|
||||
'Assign/revoke permission per role via toggle',
|
||||
'Super-admin bypass via Gate::before',
|
||||
'3 role default: super-admin, admin, user',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Notifikasi',
|
||||
icon: '🔔',
|
||||
items: [
|
||||
'Kirim notifikasi (email / in-app)',
|
||||
'Target: all users, role tertentu, atau user spesifik',
|
||||
'Log pengiriman dengan status (sent/failed)',
|
||||
'Badge counter di topbar (unread last 7 hari)',
|
||||
'Pagination history notifikasi',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Activity Logs',
|
||||
icon: '📋',
|
||||
items: [
|
||||
'Log otomatis via spatie/laravel-activitylog',
|
||||
'Filter by user, event, tanggal',
|
||||
'Bulk delete logs',
|
||||
'Tampilan subject & properties berubah',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Dashboard',
|
||||
icon: '📊',
|
||||
items: [
|
||||
'Statistik total users, admin, active, inactive',
|
||||
'Chart pendaftaran user 30 hari terakhir (Chart.js)',
|
||||
'Tabel aktivitas terbaru',
|
||||
'Quick actions (tambah user, lihat logs)',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Account Settings',
|
||||
icon: '👤',
|
||||
items: [
|
||||
'Tab Profile: nama, email, telepon, bio, avatar upload',
|
||||
'Tab Security & Password: ganti password',
|
||||
'Tab Two-Factor Auth: aktifkan/nonaktifkan TOTP 2FA',
|
||||
'Tab Danger Zone: hapus akun permanen',
|
||||
'Tab aktif persisten saat reload (via URL hash)',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'UI/UX',
|
||||
icon: '🎨',
|
||||
items: [
|
||||
'Dark mode toggle (persisted di localStorage)',
|
||||
'Sidebar responsif + burger menu mobile',
|
||||
'Breadcrumb dinamis di topbar',
|
||||
'Flash messages (success / error)',
|
||||
'Animasi masuk halaman (anim-down, anim-up, anim-left)',
|
||||
'Tab state persisten via URL hash (#tab-name)',
|
||||
'Custom scrollbar',
|
||||
'Error pages: 403, 404, 500',
|
||||
],
|
||||
},
|
||||
].map(feature => (
|
||||
<div key={feature.title} className="p-5 bg-gray-50 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-xl">{feature.icon}</span>
|
||||
<div className="text-sm font-black text-[#3D4E4B] dark:text-white">{feature.title}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-1.5">
|
||||
{feature.items.map(item => (
|
||||
<div key={item} className="flex items-start gap-2 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
<span className="text-emerald-500 font-black mt-0.5 shrink-0">✓</span>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── REST API ── */}
|
||||
<section>
|
||||
<SectionHeader id="api" title="REST API" badge="v1" badgeColor="blue" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-2">Base URL</h3>
|
||||
<CodeBlock lang="http">{'http://your-domain.com/api/v1'}</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-2">Authentication Header</h3>
|
||||
<CodeBlock lang="http">{'Authorization: Bearer {your-sanctum-token}'}</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-[#3D4E4B] dark:text-white uppercase tracking-widest mb-4">Endpoints</h3>
|
||||
<div className="border border-gray-100 dark:border-white/10 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-gray-50 dark:bg-white/5 border-b border-gray-100 dark:border-white/10">
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Auth</span>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<Endpoint method="POST" path="/api/v1/login" desc="Dapatkan Sanctum token" auth={false} />
|
||||
<Endpoint method="GET" path="/api/v1/me" desc="Data user aktif" />
|
||||
<Endpoint method="POST" path="/api/v1/logout" desc="Revoke token" />
|
||||
</div>
|
||||
<div className="px-4 py-2.5 bg-gray-50 dark:bg-white/5 border-y border-gray-100 dark:border-white/10">
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Users</span>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<Endpoint method="GET" path="/api/v1/users" desc="List semua user" />
|
||||
<Endpoint method="POST" path="/api/v1/users" desc="Buat user baru" />
|
||||
<Endpoint method="GET" path="/api/v1/users/{id}" desc="Detail user" />
|
||||
<Endpoint method="PATCH" path="/api/v1/users/{id}" desc="Update user" />
|
||||
<Endpoint method="DELETE" path="/api/v1/users/{id}" desc="Hapus user" />
|
||||
</div>
|
||||
<div className="px-4 py-2.5 bg-gray-50 dark:bg-white/5 border-y border-gray-100 dark:border-white/10">
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">App Config</span>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<Endpoint method="GET" path="/api/v1/app-config" desc="Konfigurasi aplikasi publik" auth={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 2FA ── */}
|
||||
<section>
|
||||
<SectionHeader id="2fa" title="Two-Factor Authentication" badge="TOTP" badgeColor="amber" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium leading-relaxed">
|
||||
2FA menggunakan protokol <strong className="text-[#3D4E4B] dark:text-white">TOTP (Time-based One-Time Password)</strong> yang kompatibel dengan Google Authenticator, Authy, dan 1Password.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ step: '1', title: 'Buka tab Two-Factor Auth', desc: 'Masuk ke Account Settings (/settings) → tab "Two-Factor Auth"' },
|
||||
{ step: '2', title: 'Scan QR Code', desc: 'Gunakan aplikasi authenticator (Google Authenticator / Authy) untuk scan QR' },
|
||||
{ step: '3', title: 'Masukkan kode verifikasi', desc: 'Ketik 6 digit dari aplikasi untuk mengaktifkan 2FA' },
|
||||
{ step: '4', title: 'Simpan recovery codes', desc: '8 kode cadangan tersedia — simpan di tempat aman jika kehilangan akses ke authenticator' },
|
||||
{ step: '5', title: 'Login berikutnya', desc: 'Setelah diaktifkan, setiap login akan redirect ke halaman 2FA Challenge sebelum masuk dashboard' },
|
||||
].map(s => (
|
||||
<div key={s.step} className="flex items-start gap-4 p-4 bg-gray-50 dark:bg-white/5 rounded-xl">
|
||||
<div className="w-7 h-7 rounded-full bg-[#3D4E4B] text-white text-xs font-black flex items-center justify-center shrink-0">{s.step}</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-[#3D4E4B] dark:text-white">{s.title}</div>
|
||||
<div className="text-xs text-gray-400 font-medium mt-0.5">{s.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700/40 rounded-xl">
|
||||
<p className="text-xs font-bold text-blue-700 dark:text-blue-400">2FA bersifat opsional per user. Setelah diaktifkan, setiap login akan meminta kode 6 digit dari authenticator.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── SYSTEM SETTINGS ── */}
|
||||
<section>
|
||||
<SectionHeader id="settings" title="System Settings" badge="Super Admin" badgeColor="red" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">Hanya bisa diakses oleh pengguna dengan role <Badge color="red">super-admin</Badge>. Tersedia di <code className="text-xs bg-gray-100 dark:bg-white/10 px-2 py-0.5 rounded font-mono">/system-settings</code>.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ tab: 'General & Branding', items: ['Nama aplikasi', 'Logo upload', 'Teks logo fallback', 'Registrasi publik on/off', 'Verifikasi email on/off'] },
|
||||
{ tab: 'Security & OAuth', items: ['Password minimum panjang', 'Wajib huruf besar/kecil/angka/simbol', 'Google OAuth (Client ID & Secret)', 'GitHub OAuth (Client ID & Secret)'] },
|
||||
{ tab: 'Email / SMTP', items: ['Host & port SMTP', 'Enkripsi (TLS/SSL)', 'Username & password SMTP', 'From name & address', 'Test kirim email dari UI'] },
|
||||
{ tab: 'Mobile App Control', items: ['Versi terbaru & minimum Android', 'URL Play Store', 'Mode maintenance mobile app', 'Pesan maintenance kustom'] },
|
||||
].map(t => (
|
||||
<div key={t.tab} className="p-5 bg-gray-50 dark:bg-white/5 rounded-xl">
|
||||
<div className="text-xs font-black text-[#D4A017] uppercase tracking-widest mb-3">{t.tab}</div>
|
||||
{t.items.map(i => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 shrink-0"></span>
|
||||
{i}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── STRUKTUR FOLDER ── */}
|
||||
<section>
|
||||
<SectionHeader id="structure" title="Struktur Folder" />
|
||||
<div className="bg-white dark:bg-[#1A2120] rounded-2xl border border-gray-100 dark:border-white/10 shadow-sm p-8 space-y-4">
|
||||
<CodeBlock lang="text">{`biiskit/
|
||||
├── app/
|
||||
│ ├── Http/
|
||||
│ │ ├── Controllers/ # Web + API controllers
|
||||
│ │ │ └── Api/V1/ # REST API v1 controllers
|
||||
│ │ ├── Middleware/
|
||||
│ │ │ └── HandleInertiaRequests.php # Shared props (auth, settings)
|
||||
│ │ └── Requests/ # Form request validation
|
||||
│ ├── Models/
|
||||
│ │ ├── User.php # SoftDeletes + HasRoles + HasPermissions
|
||||
│ │ ├── Setting.php # System settings key-value store
|
||||
│ │ └── NotificationLog.php # Notifikasi log
|
||||
│ └── Policies/
|
||||
│ └── UserPolicy.php # Gate policies untuk user CRUD
|
||||
│
|
||||
├── database/
|
||||
│ ├── migrations/ # PostgreSQL migrations
|
||||
│ └── seeders/
|
||||
│ └── DatabaseSeeder.php # Roles, permissions, demo users
|
||||
│
|
||||
├── resources/js/
|
||||
│ ├── Layouts/
|
||||
│ │ ├── AuthenticatedLayout.tsx # Wrapper utama + mobile sidebar
|
||||
│ │ └── components/
|
||||
│ │ ├── Sidebar.tsx # Nav dengan permission check
|
||||
│ │ └── Topbar.tsx # Breadcrumb + notif bell + dark mode
|
||||
│ ├── Pages/
|
||||
│ │ ├── Auth/ # Login, Register, Password reset
|
||||
│ │ ├── Dashboard/ # Dashboard dengan chart
|
||||
│ │ ├── Users/ # Index, Show (CRUD)
|
||||
│ │ ├── Roles/ # Roles & permission manager
|
||||
│ │ ├── Notifications/ # Kirim & history notifikasi
|
||||
│ │ ├── ActivityLogs/ # Log aktivitas
|
||||
│ │ ├── Settings/ # Account settings (profile, password, 2FA, danger zone)
|
||||
│ │ ├── TwoFactor/ # 2FA challenge page (login flow, no auth)
|
||||
│ │ ├── SystemSettings/ # Sistem config (super-admin)
|
||||
│ │ ├── Docs/ # Halaman dokumentasi ini
|
||||
│ │ └── Errors/ # 403, 404, 500 pages
|
||||
│ └── Components/
|
||||
│ ├── DataTable.tsx # Reusable sortable table
|
||||
│ └── FlashMessage.tsx # Success/error flash
|
||||
│
|
||||
├── routes/
|
||||
│ ├── web.php # Web routes (Inertia)
|
||||
│ └── api.php # API routes (Sanctum)
|
||||
│
|
||||
└── tests/
|
||||
├── Feature/ # Feature tests (Pest)
|
||||
└── Unit/ # Unit tests`}</CodeBlock>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
|
||||
interface ErrorPageProps {
|
||||
status: number;
|
||||
}
|
||||
|
||||
const messages: Record<number, { title: string; description: string }> = {
|
||||
403: {
|
||||
title: 'Access Denied',
|
||||
description: "You don't have permission to access this resource.",
|
||||
},
|
||||
404: {
|
||||
title: 'Page Not Found',
|
||||
description: "The page you're looking for doesn't exist or has been moved.",
|
||||
},
|
||||
419: {
|
||||
title: 'Session Expired',
|
||||
description: 'Your session has expired. Please refresh and try again.',
|
||||
},
|
||||
500: {
|
||||
title: 'Server Error',
|
||||
description: 'Something went wrong on our end. Please try again later.',
|
||||
},
|
||||
503: {
|
||||
title: 'Under Maintenance',
|
||||
description: 'The system is temporarily unavailable. Please check back soon.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ErrorPage({ status }: ErrorPageProps) {
|
||||
const { title, description } = messages[status] ?? {
|
||||
title: 'Unexpected Error',
|
||||
description: 'An unexpected error occurred.',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#E3EBE8] flex items-center justify-center p-6">
|
||||
<Head title={`${status} — ${title}`} />
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-3xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="bg-[#3D4E4B] px-8 py-10 text-center">
|
||||
<div className="text-7xl font-black text-white/10 tracking-tighter leading-none select-none">
|
||||
{status}
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-bold text-white tracking-tight">{title}</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-8 text-center space-y-6">
|
||||
<p className="text-sm font-semibold text-gray-400 leading-relaxed">{description}</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
{status !== 419 ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="h-10 px-6 bg-[#3D4E4B] text-white text-sm font-bold tracking-tight rounded-xl hover:bg-[#2D3A38] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="h-10 px-6 bg-[#3D4E4B] text-white text-sm font-bold tracking-tight rounded-xl hover:bg-[#2D3A38] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh Page
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="h-10 px-6 bg-white border border-gray-100 text-gray-500 text-sm font-bold tracking-tight rounded-xl hover:bg-gray-50 transition-all"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs font-bold text-gray-400 uppercase tracking-widest mt-6">
|
||||
Error {status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm, router } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types';
|
||||
import { swal } from '@/lib/swal';
|
||||
|
||||
interface NotificationLog {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
target_type: string;
|
||||
target_user?: { first_name: string, last_name: string, email: string };
|
||||
sender?: { first_name: string, last_name: string };
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface NotificationsProps extends PageProps {
|
||||
logs: {
|
||||
data: NotificationLog[];
|
||||
links: any[];
|
||||
meta: { current_page: number; last_page: number; total: number; per_page: number };
|
||||
};
|
||||
users: { id: number, first_name: string, last_name: string, email: string }[];
|
||||
}
|
||||
|
||||
export default function NotificationsIndex({ logs, users }: NotificationsProps) {
|
||||
const { data, setData, post, processing, reset, errors } = useForm<{
|
||||
title: string;
|
||||
body: string;
|
||||
image_url: string;
|
||||
deep_link: string;
|
||||
target_type: string;
|
||||
target_user_id: string;
|
||||
}>({
|
||||
title: '',
|
||||
body: '',
|
||||
image_url: '',
|
||||
deep_link: '',
|
||||
target_type: 'all',
|
||||
target_user_id: '',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('notifications.store'), {
|
||||
onSuccess: () => {
|
||||
reset();
|
||||
swal.success('Dispatched', 'Notification has been sent to devices.');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Push Notifications" />
|
||||
|
||||
<div className="flex items-center justify-between mb-8 anim-down">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">Notification Center</h1>
|
||||
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Broadcast messages and alerts to Android devices</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Composer Form */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden anim-up">
|
||||
<div className="px-6 py-4 border-b border-gray-50 bg-gray-50/30">
|
||||
<h2 className="text-sm font-bold text-[#3D4E4B] tracking-tight">Compose Broadcast</h2>
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-1">New FCM Message</p>
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-500 ml-1">Message Title</label>
|
||||
<input value={data.title} onChange={e => setData('title', e.target.value)} placeholder="e.g. Flash Sale Alert!" className={`input-field ${errors.title ? 'is-error' : ''}`} />
|
||||
{errors.title && <p className="text-[10px] text-red-500 font-bold ml-1">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-500 ml-1">Message Body</label>
|
||||
<textarea value={data.body} onChange={e => setData('body', e.target.value)} rows={3} placeholder="Write your notification message here..." className={`input-field py-3 resize-none ${errors.body ? 'is-error' : ''}`} />
|
||||
{errors.body && <p className="text-[10px] text-red-500 font-bold ml-1">{errors.body}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5 text-xs font-bold text-gray-500 ml-1">
|
||||
<label>Target Audience</label>
|
||||
<select value={data.target_type} onChange={e => setData('target_type', e.target.value)} className="input-field mt-1.5 h-10">
|
||||
<option value="all">All Users</option>
|
||||
<option value="individual">Specific User</option>
|
||||
</select>
|
||||
</div>
|
||||
{data.target_type === 'individual' && (
|
||||
<div className="space-y-1.5 text-xs font-bold text-gray-500 ml-1">
|
||||
<label>Select User</label>
|
||||
<select value={data.target_user_id} onChange={e => setData('target_user_id', e.target.value)} className="input-field mt-1.5 h-10">
|
||||
<option value="">Choose User...</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.first_name} {u.last_name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-500 ml-1">Deep Link (Optional)</label>
|
||||
<input value={data.deep_link} onChange={e => setData('deep_link', e.target.value)} placeholder="app://screen/profile" className="input-field" />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-50">
|
||||
<button type="submit" disabled={processing} className="w-full h-11 bg-[#3D4E4B] text-white text-xs font-black uppercase tracking-widest rounded-xl hover:bg-[#2D3A38] transition-all shadow-lg shadow-[#3D4E4B]/20 disabled:opacity-60">
|
||||
{processing ? 'Sending...' : 'Send Notification'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History Table */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden anim-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="px-6 py-4 border-b border-gray-50">
|
||||
<h2 className="text-sm font-bold text-[#3D4E4B] tracking-tight">Recent Broadcasts</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-gray-50/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">Message</th>
|
||||
<th className="px-6 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">Target</th>
|
||||
<th className="px-6 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">Status</th>
|
||||
<th className="px-6 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">Sent At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{logs.data.map(log => (
|
||||
<tr key={log.id} className="hover:bg-gray-50/30 transition-colors group">
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-bold text-[#3D4E4B] tracking-tight">{log.title}</div>
|
||||
<div className="text-[10px] text-gray-400 font-medium truncate max-w-[200px] mt-0.5">{log.body}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`text-[10px] font-black uppercase tracking-widest ${log.target_type === 'all' ? 'text-[#D4A017]' : 'text-blue-600'}`}>
|
||||
{log.target_type === 'all' ? 'All Devices' : (log.target_user?.first_name || 'Individual')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest border ${log.status === 'sent' ? 'bg-emerald-50 text-emerald-600 border-emerald-100' : 'bg-red-50 text-red-500 border-red-100'}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-[10px] font-bold text-gray-400">
|
||||
{new Date(log.created_at).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{logs.data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gray-50 border border-gray-100 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-gray-300">No broadcasts sent yet</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{logs.meta && logs.meta.last_page > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-50 flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
{logs.meta.total} total — page {logs.meta.current_page} of {logs.meta.last_page}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{logs.links.map((link: any, i: number) => (
|
||||
<button
|
||||
key={i}
|
||||
disabled={!link.url || link.active}
|
||||
onClick={() => link.url && router.get(link.url, {}, { preserveScroll: true })}
|
||||
className={`h-8 min-w-[2rem] px-2 rounded-lg text-xs font-bold transition-all border
|
||||
${link.active ? 'bg-[#3D4E4B] text-white border-[#3D4E4B]' : 'bg-white text-gray-400 border-gray-100 hover:border-gray-200 hover:text-[#3D4E4B]'}
|
||||
${!link.url ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
dangerouslySetInnerHTML={{ __html: link.label }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, usePage, useForm, router } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types';
|
||||
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 ProfileEditProps extends PageProps {
|
||||
mustVerifyEmail: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
function SectionCard({ title, description, children, delay = '0s' }: { title: string; description: string; children: React.ReactNode; delay?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border 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 border-gray-50 bg-gray-50/30">
|
||||
<h2 className="text-sm font-bold text-[#3D4E4B] tracking-tight">{title}</h2>
|
||||
<p className="text-xs text-gray-400 font-semibold tracking-tight mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="p-6 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputField({ label, id, type = 'text', value, onChange, error, placeholder, required = false, ...props }: any) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={id} className="block text-xs font-bold text-gray-400 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={`w-full px-4 py-2.5 rounded-xl border text-sm font-bold transition-all outline-none
|
||||
${error ? 'border-red-300 bg-red-50' : 'border-gray-100 bg-gray-50/30 focus:border-[#D4A017] focus:bg-white'}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500 font-bold ml-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfileEdit({ mustVerifyEmail, status }: ProfileEditProps) {
|
||||
const { auth } = usePage<PageProps>().props;
|
||||
const { user } = auth;
|
||||
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
|
||||
const profileForm = useForm({
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
bio: user.bio || '',
|
||||
avatar_file: null as File | null,
|
||||
_method: 'PATCH'
|
||||
});
|
||||
|
||||
const passwordForm = useForm({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const handleProfileSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
profileForm.post(route('profile.update'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => swal.success('Success', 'Profile identity synchronized.'),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
passwordForm.put(route('password.update'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
passwordForm.reset();
|
||||
swal.success('Success', 'Security token updated.');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const initials = `${user.first_name?.charAt(0) || ''}${user.last_name?.charAt(0) || ''}`.toUpperCase();
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Account Settings" />
|
||||
|
||||
<div className="mb-6 anim-down">
|
||||
<h1 className="text-lg font-bold text-[#3D4E4B] dark:text-white tracking-tight leading-none">Account Settings</h1>
|
||||
<p className="text-xs font-semibold text-gray-400 tracking-tight mt-2">Personal Identity & Security Governance</p>
|
||||
</div>
|
||||
|
||||
{/* Grid 6 6 Layout for Precision - Enforcing full-width grid-cols-2 */}
|
||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 gap-6 pb-20">
|
||||
|
||||
{/* Column 1: Identity Configuration */}
|
||||
<div className="space-y-6">
|
||||
<SectionCard title="Identity Configuration" description="Manage your personal credentials" delay="0s">
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6 mb-4">
|
||||
<div className={`w-24 h-24 rounded-2xl flex items-center justify-center text-white text-3xl font-bold shrink-0 border border-gray-100 dark:border-white/5 ${!user.avatar_url ? 'bg-[#3D4E4B]' : 'bg-white dark:bg-white/5'}`}>
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} className="w-full h-full object-cover rounded-2xl" />
|
||||
) : initials}
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 block">Identity Portrait</label>
|
||||
<FilePond files={files} onupdatefiles={items => {
|
||||
setFiles(items);
|
||||
profileForm.setData('avatar_file', items[0]?.file as File || null);
|
||||
}} allowMultiple={false} maxFiles={1} labelIdle='Portrait update' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputField label="First Designation" id="first_name" value={profileForm.data.first_name}
|
||||
onChange={(e: any) => profileForm.setData('first_name', e.target.value)}
|
||||
error={profileForm.errors.first_name} required placeholder="e.g. Alex" />
|
||||
<InputField label="Last Designation" id="last_name" value={profileForm.data.last_name}
|
||||
onChange={(e: any) => profileForm.setData('last_name', e.target.value)}
|
||||
error={profileForm.errors.last_name} required placeholder="e.g. Johnson" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputField label="Communication Channel (Email)" id="email" type="email" value={profileForm.data.email}
|
||||
onChange={(e: any) => profileForm.setData('email', e.target.value)}
|
||||
error={profileForm.errors.email} required placeholder="alex@company.com" />
|
||||
<InputField label="Contact Number (Phone)" id="phone" type="tel" value={profileForm.data.phone}
|
||||
onChange={(e: any) => profileForm.setData('phone', e.target.value)}
|
||||
error={profileForm.errors.phone} placeholder="+62..." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Professional Bio</label>
|
||||
<textarea
|
||||
value={profileForm.data.bio}
|
||||
onChange={e => profileForm.setData('bio', e.target.value)}
|
||||
rows={4}
|
||||
className="input-field py-3 resize-none"
|
||||
placeholder="Tell us about yourself..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button type="submit" disabled={profileForm.processing}
|
||||
className="px-8 py-3 bg-[#3D4E4B] text-white text-sm font-bold tracking-tight rounded-xl hover:bg-[#2D3A38] transition-all shadow-lg shadow-[#3D4E4B]/20">
|
||||
{profileForm.processing ? 'Synchronizing...' : 'Update Identity'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Security & Liquidation */}
|
||||
<div className="space-y-6 flex flex-col">
|
||||
<SectionCard title="Security Protocols" description="Authentication & Token Lifecycle" delay="0.1s">
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||
<InputField label="Current Security Token" id="current_password" type="password"
|
||||
value={passwordForm.data.current_password}
|
||||
onChange={(e: any) => passwordForm.setData('current_password', e.target.value)}
|
||||
error={passwordForm.errors.current_password} required placeholder="••••••••" />
|
||||
<InputField label="New Security Token" id="password" type="password"
|
||||
value={passwordForm.data.password}
|
||||
onChange={(e: any) => passwordForm.setData('password', e.target.value)}
|
||||
error={passwordForm.errors.password} required placeholder="••••••••" />
|
||||
<InputField label="Verify Token" id="password_confirmation" type="password"
|
||||
value={passwordForm.data.password_confirmation}
|
||||
onChange={(e: any) => passwordForm.setData('password_confirmation', e.target.value)}
|
||||
error={(passwordForm.errors as any).password_confirmation} required placeholder="••••••••" />
|
||||
|
||||
<div className="pt-4">
|
||||
<button type="submit" disabled={passwordForm.processing}
|
||||
className="w-full py-3 bg-[#D4A017] text-white text-sm font-bold tracking-tight rounded-xl hover:bg-[#B88B14] transition-all">
|
||||
{passwordForm.processing ? '...' : 'Rotate Security Tokens'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-red-100 p-6 shadow-sm anim-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-bold text-red-800 tracking-tight">Termination Zone</h3>
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-600 text-sm font-bold rounded-md border border-red-100">Critical</span>
|
||||
</div>
|
||||
<p className="text-xs text-red-600 font-semibold leading-relaxed mb-4">Once account liquidation is initiated, the process is irreversible. All associated data assets will be purged.</p>
|
||||
<button onClick={() => {
|
||||
swal.confirmDelete('Your Entire Account').then(r => {
|
||||
if(r.isConfirmed) router.delete(route('profile.destroy'));
|
||||
});
|
||||
}} className="w-full py-3 border border-red-100 text-red-600 text-xs font-bold tracking-tight rounded-xl hover:bg-red-50 transition-colors">
|
||||
Initiate Liquidation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import DangerButton from '@/Components/DangerButton';
|
||||
import InputError from '@/Components/InputError';
|
||||
import InputLabel from '@/Components/InputLabel';
|
||||
import Modal from '@/Components/Modal';
|
||||
import SecondaryButton from '@/Components/SecondaryButton';
|
||||
import TextInput from '@/Components/TextInput';
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export default function DeleteUserForm({ className = '' }) {
|
||||
const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false);
|
||||
const passwordInput = useRef();
|
||||
|
||||
const {
|
||||
data,
|
||||
setData,
|
||||
delete: destroy,
|
||||
processing,
|
||||
reset,
|
||||
errors,
|
||||
clearErrors,
|
||||
} = useForm({
|
||||
password: '',
|
||||
});
|
||||
|
||||
const confirmUserDeletion = () => {
|
||||
setConfirmingUserDeletion(true);
|
||||
};
|
||||
|
||||
const deleteUser = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
destroy(route('profile.destroy'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => passwordInput.current.focus(),
|
||||
onFinish: () => reset(),
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setConfirmingUserDeletion(false);
|
||||
|
||||
clearErrors();
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`space-y-6 ${className}`}>
|
||||
<header>
|
||||
<h2 className="text-lg font-medium text-gray-900">
|
||||
Delete Account
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Once your account is deleted, all of its resources and data
|
||||
will be permanently deleted. Before deleting your account,
|
||||
please download any data or information that you wish to
|
||||
retain.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DangerButton onClick={confirmUserDeletion}>
|
||||
Delete Account
|
||||
</DangerButton>
|
||||
|
||||
<Modal show={confirmingUserDeletion} onClose={closeModal}>
|
||||
<form onSubmit={deleteUser} className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900">
|
||||
Are you sure you want to delete your account?
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Once your account is deleted, all of its resources and
|
||||
data will be permanently deleted. Please enter your
|
||||
password to confirm you would like to permanently delete
|
||||
your account.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<InputLabel
|
||||
htmlFor="password"
|
||||
value="Password"
|
||||
className="sr-only"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
ref={passwordInput}
|
||||
value={data.password}
|
||||
onChange={(e) =>
|
||||
setData('password', e.target.value)
|
||||
}
|
||||
className="mt-1 block w-3/4"
|
||||
isFocused
|
||||
placeholder="Password"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
message={errors.password}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<SecondaryButton onClick={closeModal}>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton className="ms-3" disabled={processing}>
|
||||
Delete Account
|
||||
</DangerButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import InputError from '@/Components/InputError';
|
||||
import InputLabel from '@/Components/InputLabel';
|
||||
import PrimaryButton from '@/Components/PrimaryButton';
|
||||
import TextInput from '@/Components/TextInput';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export default function UpdatePasswordForm({ className = '' }) {
|
||||
const passwordInput = useRef();
|
||||
const currentPasswordInput = useRef();
|
||||
|
||||
const {
|
||||
data,
|
||||
setData,
|
||||
errors,
|
||||
put,
|
||||
reset,
|
||||
processing,
|
||||
recentlySuccessful,
|
||||
} = useForm({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const updatePassword = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
put(route('password.update'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => reset(),
|
||||
onError: (errors) => {
|
||||
if (errors.password) {
|
||||
reset('password', 'password_confirmation');
|
||||
passwordInput.current.focus();
|
||||
}
|
||||
|
||||
if (errors.current_password) {
|
||||
reset('current_password');
|
||||
currentPasswordInput.current.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
<header>
|
||||
<h2 className="text-lg font-medium text-gray-900">
|
||||
Update Password
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Ensure your account is using a long, random password to stay
|
||||
secure.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={updatePassword} className="mt-6 space-y-6">
|
||||
<div>
|
||||
<InputLabel
|
||||
htmlFor="current_password"
|
||||
value="Current Password"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="current_password"
|
||||
ref={currentPasswordInput}
|
||||
value={data.current_password}
|
||||
onChange={(e) =>
|
||||
setData('current_password', e.target.value)
|
||||
}
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
message={errors.current_password}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel htmlFor="password" value="New Password" />
|
||||
|
||||
<TextInput
|
||||
id="password"
|
||||
ref={passwordInput}
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} className="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel
|
||||
htmlFor="password_confirmation"
|
||||
value="Confirm Password"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="password_confirmation"
|
||||
value={data.password_confirmation}
|
||||
onChange={(e) =>
|
||||
setData('password_confirmation', e.target.value)
|
||||
}
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
message={errors.password_confirmation}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<PrimaryButton disabled={processing}>Save</PrimaryButton>
|
||||
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-gray-600">
|
||||
Saved.
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import InputError from '@/Components/InputError';
|
||||
import InputLabel from '@/Components/InputLabel';
|
||||
import PrimaryButton from '@/Components/PrimaryButton';
|
||||
import TextInput from '@/Components/TextInput';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Link, useForm, usePage } from '@inertiajs/react';
|
||||
|
||||
export default function UpdateProfileInformation({
|
||||
mustVerifyEmail,
|
||||
status,
|
||||
className = '',
|
||||
}) {
|
||||
const user = usePage().props.auth.user;
|
||||
|
||||
const { data, setData, patch, errors, processing, recentlySuccessful } =
|
||||
useForm({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
patch(route('profile.update'));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
<header>
|
||||
<h2 className="text-lg font-medium text-gray-900">
|
||||
Profile Information
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Update your account's profile information and email address.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={submit} className="mt-6 space-y-6">
|
||||
<div>
|
||||
<InputLabel htmlFor="name" value="Name" />
|
||||
|
||||
<TextInput
|
||||
id="name"
|
||||
className="mt-1 block w-full"
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
required
|
||||
isFocused
|
||||
autoComplete="name"
|
||||
/>
|
||||
|
||||
<InputError className="mt-2" message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel htmlFor="email" value="Email" />
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
className="mt-1 block w-full"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
|
||||
<InputError className="mt-2" message={errors.email} />
|
||||
</div>
|
||||
|
||||
{mustVerifyEmail && user.email_verified_at === null && (
|
||||
<div>
|
||||
<p className="mt-2 text-sm text-gray-800">
|
||||
Your email address is unverified.
|
||||
<Link
|
||||
href={route('verification.send')}
|
||||
method="post"
|
||||
as="button"
|
||||
className="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
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">
|
||||
<PrimaryButton disabled={processing}>Save</PrimaryButton>
|
||||
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-gray-600">
|
||||
Saved.
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { useState } from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types';
|
||||
import { Can } from '@/Components/Can';
|
||||
import { Portal } from '@/Components/Portal';
|
||||
import { swal } from '@/lib/swal';
|
||||
|
||||
interface RoleData {
|
||||
id: number;
|
||||
name: string;
|
||||
guard_name: string;
|
||||
permissions: string[];
|
||||
users_count: number;
|
||||
}
|
||||
|
||||
interface PermissionData {
|
||||
id: number;
|
||||
name: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface RolesPageProps extends PageProps {
|
||||
roles: RoleData[];
|
||||
permissions: PermissionData[];
|
||||
}
|
||||
|
||||
export default function RolesIndex({ roles, permissions }: RolesPageProps) {
|
||||
const groups = [...new Set(permissions.map(p => p.group))];
|
||||
const [localMatrix, setLocalMatrix] = useState<Record<number, string[]>>(() => {
|
||||
const m: Record<number, string[]> = {};
|
||||
roles.forEach(r => { m[r.id] = [...r.permissions]; });
|
||||
return m;
|
||||
});
|
||||
const [dirty, setDirty] = useState<Record<number, boolean>>({});
|
||||
const [saving, setSaving] = useState<Record<number, boolean>>({});
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newRoleName, setNewRoleName] = useState('');
|
||||
|
||||
const togglePermission = (roleId: number, permName: string) => {
|
||||
setLocalMatrix(prev => {
|
||||
const current = prev[roleId] || [];
|
||||
const updated = current.includes(permName)
|
||||
? current.filter(p => p !== permName)
|
||||
: [...current, permName];
|
||||
return { ...prev, [roleId]: updated };
|
||||
});
|
||||
setDirty(prev => ({ ...prev, [roleId]: true }));
|
||||
};
|
||||
|
||||
const handleSaveRole = async (role: RoleData) => {
|
||||
setSaving(prev => ({ ...prev, [role.id]: true }));
|
||||
router.patch(`/roles/${role.id}/permissions`, {
|
||||
permissions: localMatrix[role.id] || [],
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
setDirty(prev => ({ ...prev, [role.id]: false }));
|
||||
setSaving(prev => ({ ...prev, [role.id]: false }));
|
||||
swal.success('Saved', `Permissions updated for "${role.name}"`);
|
||||
},
|
||||
onError: () => {
|
||||
setSaving(prev => ({ ...prev, [role.id]: false }));
|
||||
swal.error('Error', 'Failed to update permissions.');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateRole = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newRoleName.trim()) return;
|
||||
router.post('/roles', { name: newRoleName.trim() }, {
|
||||
onSuccess: () => {
|
||||
setShowCreateModal(false);
|
||||
setNewRoleName('');
|
||||
swal.success('Created', 'New role created successfully.');
|
||||
},
|
||||
onError: () => swal.error('Error', 'Failed to create role.'),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteRole = async (role: RoleData) => {
|
||||
const result = await swal.confirmDelete(role.name);
|
||||
if (!result.isConfirmed) return;
|
||||
router.delete(`/roles/${role.id}`, {
|
||||
onSuccess: () => swal.success('Deleted', `Role "${role.name}" has been deleted.`),
|
||||
onError: () => swal.error('Error', 'Failed to delete role.'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Roles & Permissions" />
|
||||
|
||||
<div className="flex items-center justify-between mb-8 anim-down">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">Access Control</h1>
|
||||
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Configure hierarchical roles and granular permissions</p>
|
||||
</div>
|
||||
<Can ability="role.manage">
|
||||
<button onClick={() => setShowCreateModal(true)}
|
||||
className="h-10 px-6 rounded-xl bg-[#D4A017] text-white text-sm font-bold tracking-tight hover:bg-[#B88B14] transition-all shadow-lg shadow-[#D4A017]/20">
|
||||
New Role
|
||||
</button>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
{/* Role Summary Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{roles.map((role, idx) => {
|
||||
const count = (localMatrix[role.id] || []).length;
|
||||
const isSuperAdmin = role.name === 'super-admin';
|
||||
return (
|
||||
<div key={role.id} className="bg-white rounded-2xl border border-gray-100 p-6 shadow-sm anim-up" style={{ animationDelay: `${idx * 0.05}s` }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg border ${isSuperAdmin ? 'bg-[#3D4E4B] text-white border-[#3D4E4B]' : 'bg-white text-gray-500 border-gray-100'}`}>
|
||||
{role.name}
|
||||
</span>
|
||||
{!isSuperAdmin && (
|
||||
<Can ability="role.manage">
|
||||
<button onClick={() => handleDeleteRole(role)} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-300 hover:text-red-500 hover:bg-red-50 transition-all">
|
||||
<svg className="w-4 h-4" 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>
|
||||
</button>
|
||||
</Can>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-3xl font-black text-[#3D4E4B] tracking-tighter">
|
||||
{count}
|
||||
<span className="text-sm text-gray-200 font-bold ml-1 tracking-normal">/ {permissions.length} perms</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-black text-gray-300 uppercase tracking-widest mt-2">{role.users_count} Total Active Users</div>
|
||||
|
||||
<div className="mt-6 h-1.5 bg-gray-50 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#D4A017] transition-all duration-700" style={{ width: `${(count / permissions.length) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
{dirty[role.id] && (
|
||||
<button onClick={() => handleSaveRole(role)} disabled={saving[role.id]}
|
||||
className="mt-6 w-full h-10 bg-[#3D4E4B] text-white text-xs font-black uppercase tracking-widest rounded-xl anim-fade shadow-lg shadow-[#3D4E4B]/20 disabled:opacity-60">
|
||||
{saving[role.id] ? 'Saving…' : 'Apply Changes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Permissions Matrix */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden mb-20 anim-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="px-8 py-6 border-b border-gray-50 bg-gray-50/20">
|
||||
<h3 className="text-sm font-black text-[#3D4E4B] uppercase tracking-widest">Permissions Matrix</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-white">
|
||||
<th className="px-8 py-5 text-[10px] font-black text-gray-400 uppercase tracking-widest w-72 sticky left-0 bg-white z-10 border-b border-gray-50">Functional Permission</th>
|
||||
{roles.map(role => (
|
||||
<th key={role.id} className="px-8 py-5 text-center text-[10px] font-black text-[#3D4E4B] uppercase tracking-widest border-b border-gray-50 bg-gray-50/30">{role.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{groups.map(group => (
|
||||
<React.Fragment key={group}>
|
||||
<tr className="bg-gray-50/10">
|
||||
<td colSpan={roles.length + 1} className="px-8 py-3">
|
||||
<span className="text-[10px] font-black text-[#D4A017] uppercase tracking-[0.2em]">{group} Module</span>
|
||||
</td>
|
||||
</tr>
|
||||
{permissions.filter(p => p.group === group).map((perm) => (
|
||||
<tr key={perm.id} className="hover:bg-gray-50/30 transition-colors">
|
||||
<td className="px-8 py-4 sticky left-0 bg-white group border-r border-gray-50/50">
|
||||
<div className="text-sm font-bold text-[#3D4E4B] tracking-tight capitalize">{perm.name.replace('.', ' ')}</div>
|
||||
<div className="text-[9px] text-gray-300 font-bold uppercase tracking-widest mt-1">{perm.name}</div>
|
||||
</td>
|
||||
{roles.map(role => {
|
||||
const isChecked = (localMatrix[role.id] || []).includes(perm.name);
|
||||
const isSuperAdmin = role.name === 'super-admin';
|
||||
return (
|
||||
<td key={`${role.id}-${perm.id}`} className="px-8 py-4 text-center">
|
||||
<button
|
||||
onClick={() => !isSuperAdmin && togglePermission(role.id, perm.name)}
|
||||
disabled={isSuperAdmin}
|
||||
className={`w-8 h-8 rounded-xl transition-all flex items-center justify-center mx-auto border-2 ${
|
||||
isSuperAdmin
|
||||
? 'bg-gray-50 text-[#3D4E4B] border-gray-100 opacity-40'
|
||||
: isChecked
|
||||
? 'bg-[#3D4E4B] text-white shadow-lg shadow-[#3D4E4B]/20 border-[#3D4E4B]'
|
||||
: 'bg-white text-gray-100 hover:text-[#D4A017] border-gray-100 hover:border-[#D4A017]'
|
||||
}`}
|
||||
>
|
||||
{isChecked ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={4}><path d="M5 13l4 4L19 7" /></svg>
|
||||
) : (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Role Modal */}
|
||||
{showCreateModal && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[#3D4E4B]/60 backdrop-blur-md anim-fade">
|
||||
<div className="relative bg-white rounded-3xl shadow-2xl w-full max-w-sm overflow-hidden anim-zoom border border-gray-100">
|
||||
<div className="px-8 py-6 border-b border-gray-50">
|
||||
<h3 className="text-base font-black text-[#3D4E4B] tracking-tight">Provision New Role</h3>
|
||||
</div>
|
||||
<form onSubmit={handleCreateRole} className="p-8 space-y-8">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-gray-400 uppercase tracking-widest ml-1">Internal Role Name</label>
|
||||
<input type="text" value={newRoleName} onChange={e => setNewRoleName(e.target.value)} placeholder="e.g. auditor"
|
||||
className="input-field" required autoFocus />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button type="button" onClick={() => setShowCreateModal(false)} className="flex-1 h-11 bg-white text-gray-400 text-xs font-black uppercase tracking-widest border border-gray-100 rounded-2xl hover:bg-gray-50 transition-all">Cancel</button>
|
||||
<button type="submit" className="flex-1 h-11 bg-[#3D4E4B] text-white text-xs font-black uppercase tracking-widest rounded-2xl hover:bg-[#2D3A38] transition-all shadow-lg shadow-[#3D4E4B]/20">Provision</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
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;
|
||||
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.'); },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm, usePage } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types';
|
||||
import { swal } from '@/lib/swal';
|
||||
import axios from 'axios';
|
||||
|
||||
// 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 SystemSettingsProps extends PageProps {
|
||||
settings: Record<string, any>;
|
||||
}
|
||||
|
||||
/* ─── Reusable Components ─────────────────── */
|
||||
|
||||
function SectionCard({ title, description, children, delay = '0s' }: { title: string; description: string; children: React.ReactNode; delay?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border 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 border-gray-50 bg-gray-50/30">
|
||||
<h2 className="text-sm font-bold text-[#3D4E4B] tracking-tight">{title}</h2>
|
||||
<p className="text-xs text-gray-400 font-semibold tracking-tight mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="p-8 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleItem({ label, description, checked, onChange }: { label: string; description: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-50 last:border-0 hover:bg-gray-50/30 px-2 -mx-2 rounded-xl transition-colors group">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-[#3D4E4B] tracking-tight group-hover:text-[#D4A017] transition-colors">{label}</div>
|
||||
<div className="text-xs text-gray-400 font-medium">{description}</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" checked={checked} onChange={e => onChange(e.target.checked)} />
|
||||
<div className="w-10 h-5 bg-gray-300 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-400 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[#D4A017] peer-checked:after:border-[#D4A017] border border-gray-200"></div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputField({ label, value, onChange, type = 'text', placeholder = '', error = '', required = false, maxLength, id }: 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={e => onChange(e.target.value)} placeholder={placeholder} maxLength={maxLength}
|
||||
className={`input-field${error ? ' is-error' : ''}`}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-500 font-semibold ml-1 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SYSTEM_TABS = ['general', 'security', 'email', 'mobile'] as const;
|
||||
type SystemTab = typeof SYSTEM_TABS[number];
|
||||
|
||||
function getSystemTabFromHash(): SystemTab {
|
||||
const hash = window.location.hash.replace('#', '') as SystemTab;
|
||||
return SYSTEM_TABS.includes(hash) ? hash : 'general';
|
||||
}
|
||||
|
||||
export default function SystemSettings({ settings }: SystemSettingsProps) {
|
||||
const [activeTab, setActiveTab] = useState<SystemTab>(getSystemTabFromHash);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setActiveTab(getSystemTabFromHash());
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
const switchTab = (tab: SystemTab) => {
|
||||
window.location.hash = tab;
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
const { auth } = usePage<any>().props;
|
||||
const [testRecipient, setTestRecipient] = useState(auth?.user?.email || '');
|
||||
const [testingEmail, setTestingEmail] = useState(false);
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
if (!testRecipient) return;
|
||||
setTestingEmail(true);
|
||||
try {
|
||||
const response = await axios.post(route('system.settings.test-email'), {
|
||||
recipient: testRecipient,
|
||||
mail_host: data.settings.mail_host,
|
||||
mail_port: data.settings.mail_port,
|
||||
mail_username: data.settings.mail_username,
|
||||
mail_password: data.settings.mail_password,
|
||||
mail_encryption: data.settings.mail_encryption,
|
||||
mail_from_address: data.settings.mail_from_address,
|
||||
mail_from_name: data.settings.mail_from_name,
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
swal.success('Success', response.data.message || 'Test email sent successfully!');
|
||||
} else {
|
||||
swal.error('SMTP Error', response.data.message || 'Failed to send test email.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
swal.error('Error', error.response?.data?.message || error.message || 'An error occurred.');
|
||||
} finally {
|
||||
setTestingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
settings: {
|
||||
app_name: settings.app_name || '',
|
||||
app_logo_text: settings.app_logo_text || 'B',
|
||||
app_description: settings.app_description || '',
|
||||
allow_registration: settings.allow_registration === '1' || settings.allow_registration === true,
|
||||
require_email_verification: settings.require_email_verification === '1' || settings.require_email_verification === true,
|
||||
password_minimum_length: parseInt(settings.password_minimum_length) || 8,
|
||||
password_require_symbols: settings.password_require_symbols === '1' || settings.password_require_symbols === true,
|
||||
password_require_numbers: settings.password_require_numbers === '1' || settings.password_require_numbers === true,
|
||||
password_require_mixed_case: settings.password_require_mixed_case === '1' || settings.password_require_mixed_case === true,
|
||||
oauth_google_enabled: settings.oauth_google_enabled === '1' || settings.oauth_google_enabled === true,
|
||||
oauth_google_client_id: settings.oauth_google_client_id || '',
|
||||
oauth_google_client_secret: settings.oauth_google_client_secret || '',
|
||||
oauth_github_enabled: settings.oauth_github_enabled === '1' || settings.oauth_github_enabled === true,
|
||||
oauth_github_client_id: settings.oauth_github_client_id || '',
|
||||
oauth_github_client_secret: settings.oauth_github_client_secret || '',
|
||||
mail_host: settings.mail_host || '',
|
||||
mail_port: settings.mail_port || '',
|
||||
mail_username: settings.mail_username || '',
|
||||
mail_password: settings.mail_password || '',
|
||||
mail_encryption: settings.mail_encryption || 'tls',
|
||||
mail_from_address: settings.mail_from_address || '',
|
||||
mail_from_name: settings.mail_from_name || '',
|
||||
primary_color: settings.primary_color || '#D4A017',
|
||||
|
||||
// Mobile App
|
||||
android_latest_version: settings.android_latest_version || '1.0.0',
|
||||
android_min_version: settings.android_min_version || '1.0.0',
|
||||
android_maintenance_mode: settings.android_maintenance_mode === '1' || settings.android_maintenance_mode === true,
|
||||
android_playstore_url: settings.android_playstore_url || '',
|
||||
},
|
||||
logo_file: null as File | null,
|
||||
_method: 'PATCH',
|
||||
});
|
||||
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
setData('settings', { ...data.settings, [key]: value });
|
||||
};
|
||||
|
||||
const handleSave = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('system.settings.update'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => swal.success('Saved', 'System settings updated successfully.'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="System Settings" />
|
||||
|
||||
{/* Premium Header Row */}
|
||||
<div className="flex items-center justify-between mb-8 anim-down">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">System Configuration</h1>
|
||||
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Manage global application behavior and external protocols</p>
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={processing}
|
||||
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 disabled:cursor-not-allowed">
|
||||
{processing ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabbed Navigation 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('general')}
|
||||
className={`relative pb-3 px-1 mr-8 text-sm font-bold tracking-tight transition-colors ${activeTab === 'general' ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
|
||||
General & Branding
|
||||
{activeTab === 'general' && <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 & OAuth
|
||||
{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('email')}
|
||||
className={`relative pb-3 px-1 mr-8 text-sm font-bold tracking-tight transition-colors ${activeTab === 'email' ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
|
||||
Email Protocol
|
||||
{activeTab === 'email' && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-[#D4A017] rounded-t-full" />}
|
||||
</button>
|
||||
<button type="button" onClick={() => switchTab('mobile')}
|
||||
className={`relative pb-3 px-1 text-sm font-bold tracking-tight transition-colors ${activeTab === 'mobile' ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
|
||||
Mobile App Control
|
||||
{activeTab === 'mobile' && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-[#D4A017] rounded-t-full" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="space-y-8 pb-20">
|
||||
{activeTab === 'general' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 anim-fade">
|
||||
<SectionCard title="Application Branding" description="Logo and visual identity" delay="0.1s">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-8">
|
||||
<div className={`w-24 h-24 rounded-2xl flex items-center justify-center text-white text-3xl font-bold shrink-0 border border-gray-100 shadow-sm ${!settings.app_logo ? 'bg-[#3D4E4B]' : 'bg-white'}`}>
|
||||
{settings.app_logo ? <img src={settings.app_logo} className="w-full h-full object-contain" /> : (data.settings.app_logo_text || 'B')}
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<FilePond files={files} onupdatefiles={items => { setFiles(items); setData('logo_file', items[0]?.file as File || null); }}
|
||||
allowMultiple={false} maxFiles={1} labelIdle='Drop Logo here' />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<InputField label="App Name" value={data.settings.app_name} onChange={(v:any) => handleChange('app_name', v)} required placeholder="e.g. Biiskit Platform" />
|
||||
<InputField label="Logo Text" value={data.settings.app_logo_text} onChange={(v:any) => handleChange('app_logo_text', v)} maxLength={3} required placeholder="e.g. B" />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Public Policy" description="Manage open registration and access" delay="0.15s">
|
||||
<div className="space-y-2">
|
||||
<ToggleItem label="Public Registration" description="Allow new users to create accounts" checked={data.settings.allow_registration} onChange={v => handleChange('allow_registration', v)} />
|
||||
<ToggleItem label="Email Verification" description="Require email validation for new accounts" checked={data.settings.require_email_verification} onChange={v => handleChange('require_email_verification', v)} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 anim-fade">
|
||||
<SectionCard title="Password Standards" description="Complexity requirements for user access" delay="0.1s">
|
||||
<div className="space-y-2">
|
||||
<ToggleItem label="Require Symbols" description="Include special characters (!@#$)" checked={data.settings.password_require_symbols} onChange={v => handleChange('password_require_symbols', v)} />
|
||||
<ToggleItem label="Require Numbers" description="Include numerical digits (0-9)" checked={data.settings.password_require_numbers} onChange={v => handleChange('password_require_numbers', v)} />
|
||||
<ToggleItem label="Require Mixed Case" description="Force Uppercase and Lowercase letters" checked={data.settings.password_require_mixed_case} onChange={v => handleChange('password_require_mixed_case', v)} />
|
||||
<div className="flex items-center justify-between py-4 mt-2 border-t border-gray-50">
|
||||
<span className="text-sm font-bold text-[#3D4E4B]">Minimum Length</span>
|
||||
<input type="number" value={data.settings.password_minimum_length} onChange={e => handleChange('password_minimum_length', parseInt(e.target.value))} className="w-16 h-10 input-field text-center px-2 text-[#D4A017] font-bold" />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="OAuth Providers" description="Third-party authentication protocols" delay="0.15s">
|
||||
<div className="space-y-6">
|
||||
{['google', 'github'].map(prov => (
|
||||
<div key={prov} className="p-6 rounded-2xl bg-gray-50/50 border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="text-sm font-bold text-[#3D4E4B] tracking-tight capitalize flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${data.settings[`oauth_${prov}_enabled` as keyof typeof data.settings] ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
{prov} Login
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer scale-90">
|
||||
<input type="checkbox" className="sr-only peer" checked={data.settings[`oauth_${prov}_enabled` as keyof typeof data.settings]} onChange={e => handleChange(`oauth_${prov}_enabled`, e.target.checked)} />
|
||||
<div className="w-10 h-5 bg-gray-300 rounded-full peer peer-checked:bg-[#D4A017] after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-400 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full border border-gray-200"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<InputField label="Client ID" value={data.settings[`oauth_${prov}_client_id` as keyof typeof data.settings]} onChange={(v:any) => handleChange(`oauth_${prov}_client_id`, v)} placeholder={`Enter ${prov} Client ID`} />
|
||||
<InputField label="Client Secret" type="password" value={data.settings[`oauth_${prov}_client_secret` as keyof typeof data.settings]} onChange={(v:any) => handleChange(`oauth_${prov}_client_secret`, v)} placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'email' && (
|
||||
<div className="max-w-5xl space-y-8 anim-fade">
|
||||
<SectionCard title="Mail Transmission" description="SMTP server configuration for system notifications" delay="0.1s">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="col-span-2"><InputField label="Mail Host" value={data.settings.mail_host} onChange={(v:any) => handleChange('mail_host', v)} placeholder="e.g. smtp.mailtrap.io" required /></div>
|
||||
<InputField label="Port" value={data.settings.mail_port} onChange={(v:any) => handleChange('mail_port', v)} placeholder="e.g. 587" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<InputField label="Username" value={data.settings.mail_username} onChange={(v:any) => handleChange('mail_username', v)} placeholder="e.g. user_key_123" />
|
||||
<InputField label="Password" type="password" value={data.settings.mail_password} onChange={(v:any) => handleChange('mail_password', v)} placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6 pt-6 md:pt-0 border-t md:border-t-0 md:border-l border-gray-100 md:pl-8">
|
||||
<InputField label="Sender Address" value={data.settings.mail_from_address} onChange={(v:any) => handleChange('mail_from_address', v)} placeholder="e.g. no-reply@app.com" required />
|
||||
<InputField label="Sender Name" value={data.settings.mail_from_name} onChange={(v:any) => handleChange('mail_from_name', v)} placeholder="e.g. System Admin" required />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="SMTP Connection Test" description="Verify your SMTP transmission parameters by sending a test email" delay="0.15s">
|
||||
<div className="max-w-xl space-y-6">
|
||||
<div className="p-4 rounded-xl bg-gray-50/50 border border-gray-100 flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-[#D4A017] mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-xs text-[#3D4E4B] font-semibold leading-relaxed">
|
||||
This test will use the SMTP values entered in the form above. You don't need to save the configuration first to test it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 items-end">
|
||||
<div className="sm:col-span-2">
|
||||
<InputField
|
||||
label="Recipient Email Address"
|
||||
value={testRecipient}
|
||||
onChange={setTestRecipient}
|
||||
placeholder="e.g. you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendTestEmail}
|
||||
disabled={testingEmail || !testRecipient}
|
||||
className="h-10 px-6 bg-[#3D4E4B] text-white text-xs font-bold tracking-tight rounded-xl hover:bg-[#2D3A38] transition-all shadow-md shadow-[#3D4E4B]/10 disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{testingEmail ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Transmitting...</span>
|
||||
</>
|
||||
) : 'Send Test Email'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'mobile' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 anim-fade">
|
||||
<SectionCard title="Version Management" description="Control Android application lifecycle" delay="0.1s">
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<InputField label="Latest Version" value={data.settings.android_latest_version} onChange={(v:any) => handleChange('android_latest_version', v)} placeholder="e.g. 1.2.0" />
|
||||
<InputField label="Min Required Version" value={data.settings.android_min_version} onChange={(v:any) => handleChange('android_min_version', v)} placeholder="e.g. 1.0.5" />
|
||||
</div>
|
||||
<InputField label="Play Store URL" value={data.settings.android_playstore_url} onChange={(v:any) => handleChange('android_playstore_url', v)} placeholder="https://play.google.com/store/apps/details?id=..." />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Mobile Availability" description="Control API accessibility for devices" delay="0.15s">
|
||||
<div className="space-y-4">
|
||||
<ToggleItem label="Maintenance Mode" description="Block Android API access for maintenance" checked={data.settings.android_maintenance_mode} onChange={v => handleChange('android_maintenance_mode', v)} />
|
||||
<div className="p-4 rounded-xl bg-amber-50 border border-amber-100 mt-4">
|
||||
<p className="text-[10px] font-bold text-amber-800 leading-relaxed uppercase tracking-widest mb-1">Warning</p>
|
||||
<p className="text-[11px] text-amber-700 font-medium">Enabling maintenance mode will return a 503 error to all mobile devices connecting to the API.</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
|
||||
export default function TwoFactorChallenge() {
|
||||
const [useRecovery, setUseRecovery] = useState(false);
|
||||
const { data, setData, post, processing, errors } = useForm({ code: '' });
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('two-factor.verify'), { preserveScroll: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#E3EBE8] flex items-center justify-center p-4">
|
||||
<Head title="Two-Factor Authentication" />
|
||||
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-[#3D4E4B] mb-4">
|
||||
<svg className="w-7 h-7 text-[#D4A017]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-black text-[#3D4E4B] tracking-tight">Two-Factor Authentication</h1>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1">
|
||||
{useRecovery ? 'Enter a recovery code to continue' : 'Enter the 6-digit code from your authenticator app'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">
|
||||
{useRecovery ? 'Recovery Code' : 'Authentication Code'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode={useRecovery ? 'text' : 'numeric'}
|
||||
maxLength={useRecovery ? 21 : 6}
|
||||
value={data.code}
|
||||
onChange={e => setData('code', e.target.value)}
|
||||
autoFocus
|
||||
className={`w-full h-12 border rounded-xl px-4 text-center font-mono font-bold text-lg tracking-[0.4em] outline-none transition-all
|
||||
${errors.code ? 'border-red-300 bg-red-50' : 'border-gray-200 focus:border-[#3D4E4B] focus:ring-2 focus:ring-[#3D4E4B]/10'}`}
|
||||
placeholder={useRecovery ? 'xxxxxxxxxx-xxxxxxxxxx' : '000000'}
|
||||
/>
|
||||
{errors.code && (
|
||||
<p className="text-xs text-red-500 font-semibold mt-1.5">{errors.code}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing || data.code.length < (useRecovery ? 5 : 6)}
|
||||
className="w-full h-11 bg-[#3D4E4B] text-white text-sm font-bold rounded-xl hover:bg-[#2D3A38] transition-all disabled:opacity-60"
|
||||
>
|
||||
{processing ? 'Verifying...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => { setUseRecovery(!useRecovery); setData('code', ''); }}
|
||||
className="text-xs font-bold text-[#3D4E4B] hover:underline"
|
||||
>
|
||||
{useRecovery ? 'Use authenticator code instead' : 'Use a recovery code'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a href="/login" className="text-xs font-semibold text-gray-400 hover:text-[#3D4E4B]">
|
||||
← Back to login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useState } from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm, router } from '@inertiajs/react';
|
||||
import { swal } from '@/lib/swal';
|
||||
import Swal from 'sweetalert2';
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
qr_code: string;
|
||||
secret: string;
|
||||
recovery_codes: string[];
|
||||
}
|
||||
|
||||
export default function TwoFactorSetup({ enabled, qr_code, secret, recovery_codes }: Props) {
|
||||
const [copiedSecret, setCopiedSecret] = useState(false);
|
||||
const [showCodes, setShowCodes] = useState(false);
|
||||
|
||||
const { data, setData, post, processing, errors, reset } = useForm({ code: '' });
|
||||
const disableForm = useForm({ password: '' });
|
||||
|
||||
const handleEnable = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('two-factor.enable'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { reset(); swal.success('Enabled', '2FA is now active on your account.'); },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
const { value: password } = await Swal.fire({
|
||||
title: 'Disable 2FA',
|
||||
text: 'Enter your password to confirm disabling Two-Factor Authentication.',
|
||||
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 immediately.', 'Regenerate');
|
||||
if (result.isConfirmed) {
|
||||
router.post(route('two-factor.recovery-codes'), {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { setShowCodes(true); swal.success('Regenerated', 'New recovery codes have been generated.'); },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copySecret = () => {
|
||||
navigator.clipboard.writeText(secret);
|
||||
setCopiedSecret(true);
|
||||
setTimeout(() => setCopiedSecret(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Two-Factor Authentication" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8 anim-down">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[#3D4E4B] dark:text-white tracking-tight leading-none">Two-Factor Authentication</h1>
|
||||
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Protect your account with an additional verification step</p>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold ${enabled ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-amber-50 text-amber-700 border border-amber-200'}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${enabled ? 'bg-emerald-500' : 'bg-amber-400'}`}></span>
|
||||
{enabled ? '2FA Active' : '2FA Not Enabled'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl space-y-6">
|
||||
{/* Info Panel */}
|
||||
<div className="p-5 bg-amber-50 border border-amber-200 rounded-2xl flex items-start gap-3 anim-down">
|
||||
<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">What is Two-Factor Authentication?</p>
|
||||
<p className="text-[11px] text-amber-700 font-medium leading-relaxed">
|
||||
2FA adds an extra layer of security by requiring a one-time code from your authenticator app in addition to your password when signing in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup Card */}
|
||||
{!enabled ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden anim-up">
|
||||
<div className="px-6 py-4 border-b border-gray-50 bg-gray-50/30">
|
||||
<h2 className="text-sm font-bold text-[#3D4E4B]">Setup Authenticator App</h2>
|
||||
<p className="text-xs text-gray-400 font-semibold mt-1">Scan the QR code with Google Authenticator, Authy, or any TOTP app</p>
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col md:flex-row gap-10 items-center md:items-start">
|
||||
{/* QR Code */}
|
||||
<div className="shrink-0">
|
||||
<div className="p-4 bg-white border-2 border-gray-100 rounded-2xl inline-block shadow-sm">
|
||||
<img src={qr_code} alt="2FA QR Code" className="w-48 h-48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions + Verify */}
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
'Install an authenticator app (Google Authenticator, Authy, 1Password)',
|
||||
'Scan the QR code or enter the manual key below',
|
||||
'Enter the 6-digit code from your app to verify and activate',
|
||||
].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>
|
||||
|
||||
{/* Manual Key */}
|
||||
<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">
|
||||
{secret}
|
||||
</code>
|
||||
<button onClick={copySecret}
|
||||
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>
|
||||
|
||||
{/* Verify form */}
|
||||
<form onSubmit={handleEnable} 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={data.code}
|
||||
onChange={e => setData('code', e.target.value)}
|
||||
className="input-field w-full text-center tracking-[0.5em] font-bold text-lg"
|
||||
placeholder="000000"
|
||||
/>
|
||||
{errors.code && <p className="text-xs text-red-500 font-semibold mt-1">{errors.code}</p>}
|
||||
</div>
|
||||
<button type="submit" disabled={processing || 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">
|
||||
{processing ? 'Verifying...' : 'Enable Two-Factor Authentication'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Enabled state */
|
||||
<div className="space-y-4">
|
||||
{/* Status card */}
|
||||
<div className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-6 flex items-center gap-4 anim-up">
|
||||
<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 is Active</div>
|
||||
<div className="text-xs text-gray-400 font-medium mt-0.5">Your account is secured with TOTP authentication.</div>
|
||||
</div>
|
||||
<button onClick={handleDisable}
|
||||
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>
|
||||
|
||||
{/* Recovery Codes */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden anim-up" style={{ animationDelay: '0.05s' }}>
|
||||
<div className="px-6 py-4 border-b border-gray-50 bg-gray-50/30 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-[#3D4E4B]">Recovery Codes</h2>
|
||||
<p className="text-xs text-gray-400 font-semibold mt-1">Store these codes safely — use them if you lose access to your authenticator</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setShowCodes(!showCodes)}
|
||||
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'}
|
||||
</button>
|
||||
<button onClick={handleRegenerate}
|
||||
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>
|
||||
</div>
|
||||
{showCodes && recovery_codes.length > 0 && (
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{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-4">
|
||||
⚠ Each code can only be used once. Regenerate if compromised.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, usePage, router } from '@inertiajs/react';
|
||||
import { PageProps, User } from '@/types';
|
||||
import { Can } from '@/Components/Can';
|
||||
import { DataTable } from '@/Components/DataTable';
|
||||
import { Portal } from '@/Components/Portal';
|
||||
import { swal } from '@/lib/swal';
|
||||
import _ from 'lodash';
|
||||
|
||||
interface UsersPageProps extends PageProps {
|
||||
users: { data: User[]; meta: any; links: any[]; };
|
||||
availableRoles: string[];
|
||||
filters: any;
|
||||
}
|
||||
|
||||
/* ─── User Create/Edit Modal (Professional & Compact) ─────────────── */
|
||||
/* ─── User Create/Edit Modal (Professional & Compact) ─────────────── */
|
||||
function UserModal({ user, availableRoles, onClose }: {
|
||||
user: Partial<User> | null;
|
||||
availableRoles: string[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const existingRoles = user?.id ? ((user as any).roles || []).map((r: any) => r.name || r) : [];
|
||||
const [form, setForm] = useState({
|
||||
first_name: user?.first_name || '',
|
||||
last_name: user?.last_name || '',
|
||||
email: user?.email || '',
|
||||
password: '',
|
||||
status: user?.status || 'active',
|
||||
roles: existingRoles as string[],
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const validate = () => {
|
||||
const e: Record<string, string> = {};
|
||||
if (!form.first_name.trim()) e.first_name = 'Required';
|
||||
if (!form.last_name.trim()) e.last_name = 'Required';
|
||||
if (!form.email.trim()) e.email = 'Required';
|
||||
else if (!/\S+@\S+\.\S+/.test(form.email)) e.email = 'Invalid';
|
||||
if (!user?.id && !form.password) e.password = 'Required';
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
const toggleRole = (roleName: string) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
roles: prev.roles.includes(roleName) ? prev.roles.filter(r => r !== roleName) : [...prev.roles, roleName],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setProcessing(true);
|
||||
const payload = { ...form };
|
||||
if (user?.id) {
|
||||
router.patch(`/users/${user.id}`, payload, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { onClose(); swal.success('Updated', 'User updated successfully.'); },
|
||||
onError: (errs) => { setErrors(errs as any); setProcessing(false); },
|
||||
});
|
||||
} else {
|
||||
router.post('/users', payload, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { onClose(); swal.success('Created', 'New user created successfully.'); },
|
||||
onError: (errs) => { setErrors(errs as any); setProcessing(false); },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[#3D4E4B]/60 backdrop-blur-md anim-fade">
|
||||
<div className="bg-white w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden anim-zoom border border-gray-100">
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[#3D4E4B] tracking-tight">{user?.id ? 'Edit user' : 'New user'}</h2>
|
||||
<p className="text-sm text-gray-400 font-medium mt-1">Fill in the user details below.</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-50 rounded-xl transition-colors">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">First Name</label>
|
||||
<input type="text" value={form.first_name} onChange={e => setForm({ ...form, first_name: e.target.value })} className={`input-field${errors.first_name ? ' is-error' : ''}`} placeholder="John" required />
|
||||
{errors.first_name && <p className="text-[10px] text-red-500 font-bold ml-1">{errors.first_name}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">Last Name</label>
|
||||
<input type="text" value={form.last_name} onChange={e => setForm({ ...form, last_name: e.target.value })} className={`input-field${errors.last_name ? ' is-error' : ''}`} placeholder="Doe" required />
|
||||
{errors.last_name && <p className="text-[10px] text-red-500 font-bold ml-1">{errors.last_name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">Email Address</label>
|
||||
<input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} className={`input-field${errors.email ? ' is-error' : ''}`} placeholder="john.doe@example.com" required />
|
||||
{errors.email && <p className="text-[10px] text-red-500 font-bold ml-1">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
{!user?.id && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">Initial Password</label>
|
||||
<input type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} className="input-field" placeholder="••••••••" required />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">Assigned Roles</label>
|
||||
<div className="flex flex-wrap gap-2 p-1">
|
||||
{availableRoles.map(role => (
|
||||
<button
|
||||
key={role} type="button"
|
||||
onClick={() => toggleRole(role)}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-bold tracking-tight transition-all border ${form.roles.includes(role) ? 'bg-[#3D4E4B] text-white border-[#3D4E4B] shadow-md shadow-[#3D4E4B]/20' : 'bg-white text-gray-400 border-gray-100 hover:border-gray-200'}`}
|
||||
>
|
||||
{role}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button type="button" onClick={onClose} className="flex-1 h-9 bg-white border border-gray-200 rounded-xl text-sm font-bold text-gray-400 hover:bg-gray-50 transition-all">Cancel</button>
|
||||
<button type="submit" disabled={processing} className="flex-1 h-9 bg-[#3D4E4B] text-white rounded-xl text-sm font-bold hover:bg-[#2D3A38] transition-all shadow-lg shadow-[#3D4E4B]/20 flex items-center justify-center gap-2 disabled:opacity-60">
|
||||
{processing ? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> : (user?.id ? 'Save changes' : 'Create user')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ─── Users Page ──────────────────────────────────────────────────── */
|
||||
export default function UsersIndex({ users, availableRoles, filters }: UsersPageProps) {
|
||||
const { permissions } = usePage<PageProps>().props.auth;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<(number | string)[]>([]);
|
||||
const [localFilters, setLocalFilters] = useState({
|
||||
search: filters.search || '',
|
||||
status: filters.status || '',
|
||||
role: filters.role || '',
|
||||
trashed: filters.trashed || '',
|
||||
per_page: filters.per_page || 15,
|
||||
sort_field: filters.sort_field || 'created_at',
|
||||
sort_direction: filters.sort_direction || 'desc'
|
||||
});
|
||||
|
||||
// Sync local filters with server props
|
||||
React.useEffect(() => {
|
||||
setLocalFilters({
|
||||
search: filters.search || '',
|
||||
status: filters.status || '',
|
||||
role: filters.role || '',
|
||||
sort_field: filters.sort_field || 'created_at',
|
||||
sort_direction: filters.sort_direction || 'desc',
|
||||
per_page: filters.per_page || 15,
|
||||
trashed: filters.trashed || '',
|
||||
});
|
||||
}, [filters]);
|
||||
|
||||
const debouncedFilter = useCallback(_.debounce((params) => {
|
||||
setIsLoading(true);
|
||||
router.get('/users', params, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
only: ['users', 'filters'],
|
||||
onFinish: () => setIsLoading(false)
|
||||
});
|
||||
}, 400), []);
|
||||
|
||||
const updateFilter = (key: string, value: any) => {
|
||||
const newFilters = { ...localFilters, [key]: value };
|
||||
setLocalFilters(newFilters);
|
||||
|
||||
const params = { ...newFilters, page: 1 };
|
||||
setSelectedIds([]); // Clear selection on filter change
|
||||
debouncedFilter(params);
|
||||
};
|
||||
|
||||
const handleBulkAction = (action: string) => {
|
||||
const actionMap: any = {
|
||||
archive: { url: route('users.bulk-archive'), text: 'Archive' },
|
||||
restore: { url: route('users.bulk-restore'), text: 'Restore' },
|
||||
delete: { url: route('users.bulk-force-delete'), text: 'Delete' }
|
||||
};
|
||||
const config = actionMap[action];
|
||||
|
||||
swal.confirm(`${config.text} Selected?`, `Are you sure you want to ${action} ${selectedIds.length} users?`, config.text)
|
||||
.then(result => {
|
||||
if (result.isConfirmed) {
|
||||
router.post(config.url, { ids: selectedIds }, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
setSelectedIds([]);
|
||||
swal.success('Success', `${selectedIds.length} users ${action}d successfully.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'User',
|
||||
accessorKey: 'first_name',
|
||||
sortable: true,
|
||||
cell: (u: User) => (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold overflow-hidden shrink-0 ${u.deleted_at ? 'bg-gray-400' : 'bg-[#3D4E4B]'}`}>
|
||||
{u.avatar_url ? <img src={u.avatar_url} className="w-full h-full object-cover" /> : `${u.first_name?.charAt(0)}${u.last_name?.charAt(0)}`}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-bold tracking-tight ${u.deleted_at ? 'text-gray-400 line-through' : 'text-[#3D4E4B]'}`}>{u.first_name} {u.last_name}</div>
|
||||
<div className="text-xs text-gray-400 font-semibold mt-0.5">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Roles',
|
||||
accessorKey: 'roles',
|
||||
cell: (u: User) => (
|
||||
<div className="flex gap-1.5">
|
||||
{(u as any).roles?.length ? (u as any).roles.map((r: any) => (
|
||||
<span key={r.name || r} className={`px-2 py-0.5 text-sm font-bold tracking-tight bg-white border border-gray-100 rounded-md ${u.deleted_at ? 'text-gray-300' : 'text-gray-500'}`}>{r.name || r}</span>
|
||||
)) : <span className="text-sm font-semibold text-gray-300 italic tracking-tight">Unassigned</span>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
sortable: true,
|
||||
cell: (u: User) => (
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold tracking-tight border border-gray-100 ${u.deleted_at ? 'text-gray-300 bg-gray-50/50' : (u.status === 'active' ? 'text-green-600 bg-white border-green-100' : 'text-gray-400 bg-white')}`}>
|
||||
<span className={`w-1 h-1 rounded-full ${u.deleted_at ? 'bg-gray-300' : (u.status === 'active' ? 'bg-green-500' : 'bg-gray-300')}`} />
|
||||
{u.deleted_at ? 'Archived' : u.status}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: localFilters.trashed === 'only' ? 'Archived at' : 'Joined',
|
||||
accessorKey: localFilters.trashed === 'only' ? 'deleted_at' : 'created_at',
|
||||
sortable: true,
|
||||
cell: (u: User) => (
|
||||
<span className="text-sm font-semibold text-gray-400 tracking-tight">
|
||||
{new Date((u as any)[u.deleted_at ? 'deleted_at' : 'created_at']).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const isTrashed = localFilters.trashed === 'only';
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="Users" />
|
||||
|
||||
{/* Row 1: title + toolbar */}
|
||||
<div className="flex items-center justify-between mb-8 anim-down">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">User Management</h1>
|
||||
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Maintain and configure the global user registry</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-[240px]">
|
||||
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input type="text" placeholder="Search users…" value={localFilters.search} onChange={e => updateFilter('search', e.target.value)}
|
||||
className="w-full h-11 pl-10 pr-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 placeholder-gray-400 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm" />
|
||||
</div>
|
||||
|
||||
{!isTrashed && (
|
||||
<>
|
||||
<select value={localFilters.status} onChange={e => updateFilter('status', e.target.value)}
|
||||
className="h-11 px-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm cursor-pointer min-w-[140px]">
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<select value={localFilters.role} onChange={e => updateFilter('role', e.target.value)}
|
||||
className="h-11 px-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm cursor-pointer min-w-[140px]">
|
||||
<option value="">All Roles</option>
|
||||
{availableRoles.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isTrashed && (
|
||||
<div className="flex items-center gap-2 border-l border-gray-100 pl-3">
|
||||
<a href={route('users.export')} className="flex items-center gap-2 h-11 px-4 rounded-2xl bg-white border border-gray-100 text-[#3D4E4B] text-sm font-bold hover:bg-gray-50 transition-all shadow-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
Export
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isTrashed && (
|
||||
<Can ability="user.create">
|
||||
<button onClick={() => { setEditUser(null); setShowModal(true); }}
|
||||
className="flex items-center gap-2 h-11 px-6 rounded-2xl bg-[#D4A017] text-white text-sm font-bold hover:bg-[#B88B14] transition-all shadow-lg shadow-[#D4A017]/20 hover:-translate-y-0.5 active:translate-y-0 ml-1">
|
||||
New user
|
||||
</button>
|
||||
</Can>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: tabs + archived notice */}
|
||||
<div className="flex items-center justify-between mb-5 anim-down" style={{ animationDelay: '0.05s' }}>
|
||||
<div className="flex items-center gap-1 border-b border-gray-100 w-full">
|
||||
<button type="button" onClick={() => updateFilter('trashed', '')}
|
||||
className={`relative pb-3 px-1 mr-4 text-sm font-bold tracking-tight transition-colors ${!isTrashed ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
|
||||
Active users
|
||||
{!isTrashed && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-[#D4A017] rounded-t-full" />}
|
||||
</button>
|
||||
<button type="button" onClick={() => updateFilter('trashed', 'only')}
|
||||
className={`relative pb-3 px-1 text-sm font-bold tracking-tight transition-colors ${isTrashed ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
|
||||
Archived
|
||||
{isTrashed && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-red-400 rounded-t-full" />}
|
||||
</button>
|
||||
{isTrashed && (
|
||||
<span className="ml-auto mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-600 bg-amber-50 border border-amber-100 px-2.5 py-1 rounded-lg">
|
||||
<svg className="w-3 h-3" 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>
|
||||
Archived users cannot log in
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anim-up relative" style={{ animationDelay: '0.08s' }}>
|
||||
<DataTable
|
||||
data={users.data} columns={columns as any} meta={users.meta} links={users.links} filters={localFilters}
|
||||
onSort={(f, d) => { updateFilter('sort_field', f); updateFilter('sort_direction', d); }}
|
||||
isLoading={isLoading}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
canEdit={!isTrashed && permissions.includes('user.edit')}
|
||||
emptyAction={!isTrashed && permissions.includes('user.create') ? (
|
||||
<button onClick={() => { setEditUser(null); setShowModal(true); }}
|
||||
className="h-9 px-5 rounded-xl bg-[#D4A017] text-white text-xs font-bold hover:bg-[#B88B14] transition-all shadow-md shadow-[#D4A017]/20">
|
||||
Add first user
|
||||
</button>
|
||||
) : undefined}
|
||||
onEdit={(u) => { setEditUser(u); setShowModal(true); }}
|
||||
onDelete={!isTrashed ? (u) => {
|
||||
swal.confirm('Archive user?', `Move ${u.first_name} ${u.last_name} to the archived list?`, 'Archive')
|
||||
.then(result => {
|
||||
if (result.isConfirmed) router.delete(`/users/${u.id}`, { preserveScroll: true, onSuccess: () => swal.success('Archived', 'User archived successfully.') });
|
||||
});
|
||||
} : undefined}
|
||||
onRestore={isTrashed ? (u) => {
|
||||
swal.confirm('Restore user?', `Restore ${u.first_name} ${u.last_name} to active status?`, 'Restore')
|
||||
.then(result => {
|
||||
if (result.isConfirmed) router.post(`/users/${u.id}/restore`, {}, { preserveScroll: true, onSuccess: () => swal.success('Restored', 'User restored successfully.') });
|
||||
});
|
||||
} : undefined}
|
||||
onPermanentDelete={isTrashed ? (u) => {
|
||||
swal.confirmDelete(`${u.first_name} ${u.last_name}`)
|
||||
.then(result => {
|
||||
if (result.isConfirmed) router.delete(`/users/${u.id}/force-delete`, { preserveScroll: true, onSuccess: () => swal.success('Deleted', 'User permanently deleted.') });
|
||||
});
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showModal && <UserModal user={editUser} availableRoles={availableRoles} onClose={() => { setShowModal(false); setEditUser(null); }} />}
|
||||
|
||||
{/* Floating Bulk Actions Bar */}
|
||||
<Portal>
|
||||
<div className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-40 transition-all duration-500 ${selectedIds.length > 0 ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'}`}>
|
||||
<div className="bg-[#3D4E4B] rounded-2xl shadow-2xl px-6 py-4 flex items-center gap-6 border border-white/10 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-3 pr-6 border-r border-white/10">
|
||||
<span className="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center text-white text-xs font-bold">{selectedIds.length}</span>
|
||||
<span className="text-white text-sm font-bold tracking-tight">Items selected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isTrashed ? (
|
||||
<>
|
||||
<button onClick={() => handleBulkAction('restore')} className="h-10 px-5 rounded-xl bg-green-500 text-white text-xs font-bold hover:bg-green-600 transition-all flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
Bulk Restore
|
||||
</button>
|
||||
<button onClick={() => handleBulkAction('delete')} className="h-10 px-5 rounded-xl bg-red-500 text-white text-xs font-bold hover:bg-red-600 transition-all flex items-center gap-2">
|
||||
<svg className="w-4 h-4" 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>
|
||||
Bulk Delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => handleBulkAction('archive')} className="h-10 px-5 rounded-xl bg-white/10 text-white text-xs font-bold hover:bg-white/20 transition-all flex items-center gap-2">
|
||||
<svg className="w-4 h-4" 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>
|
||||
Bulk Archive
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setSelectedIds([])} className="h-10 px-4 text-white/40 text-xs font-bold hover:text-white transition-colors">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { PageProps, User } from '@/types';
|
||||
|
||||
interface UserShowProps extends PageProps {
|
||||
viewUser: User & {
|
||||
roles: any[];
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-0 py-3.5 border-b border-gray-50 last:border-0">
|
||||
<dt className="text-xs font-semibold text-gray-500 sm:w-44 shrink-0 mt-0.5">{label}</dt>
|
||||
<dd className="text-sm font-semibold text-[#3D4E4B] tracking-tight">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserShow({ viewUser }: UserShowProps) {
|
||||
const initials = `${viewUser.first_name?.charAt(0) || ''}${viewUser.last_name?.charAt(0) || ''}`.toUpperCase();
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`${viewUser.first_name} ${viewUser.last_name} — Users`} />
|
||||
|
||||
<div className="flex items-center gap-2 text-sm mb-6 anim-fade">
|
||||
<Link href="/users" className="text-gray-400 hover:text-[#3D4E4B] transition-colors font-semibold">Users</Link>
|
||||
<svg className="w-3.5 h-3.5 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-[#3D4E4B] font-bold">{viewUser.first_name} {viewUser.last_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: profile card */}
|
||||
<div className="lg:col-span-1 space-y-4 anim-up">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-[#3D4E4B] flex items-center justify-center text-white text-2xl font-bold overflow-hidden mb-4">
|
||||
{viewUser.avatar_url
|
||||
? <img src={viewUser.avatar_url} className="w-full h-full object-cover" alt="" />
|
||||
: initials}
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-[#3D4E4B] tracking-tight">{viewUser.first_name} {viewUser.last_name}</h2>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">{viewUser.email}</p>
|
||||
<div className="flex flex-wrap justify-center gap-1.5 mt-3">
|
||||
{viewUser.roles?.map((role: any) => (
|
||||
<span key={role.name || role} className="px-2.5 py-1 text-xs font-bold bg-white text-gray-500 border border-gray-100 rounded-md">
|
||||
{role.name || role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold border ${
|
||||
viewUser.status === 'active'
|
||||
? 'text-green-600 bg-white border-green-100'
|
||||
: 'text-gray-400 bg-white border-gray-100'
|
||||
}`}>
|
||||
<span className={`w-1 h-1 rounded-full ${viewUser.status === 'active' ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
{viewUser.status === 'active' ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 space-y-1">
|
||||
<Link href="/users"
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold text-gray-500 hover:bg-gray-50 hover:text-[#3D4E4B] transition-colors w-full">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: details */}
|
||||
<div className="lg:col-span-2 space-y-4 anim-up" style={{ animationDelay: '0.08s' }}>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-50 bg-gray-50/30">
|
||||
<h3 className="text-sm font-bold text-[#3D4E4B] tracking-tight">Personal Information</h3>
|
||||
</div>
|
||||
<div className="px-6">
|
||||
<dl>
|
||||
<InfoRow label="First name">{viewUser.first_name}</InfoRow>
|
||||
<InfoRow label="Last name">{viewUser.last_name}</InfoRow>
|
||||
<InfoRow label="Email">{viewUser.email}</InfoRow>
|
||||
<InfoRow label="User ID"><span className="font-mono text-gray-400 text-xs">#{viewUser.id}</span></InfoRow>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-50 bg-gray-50/30">
|
||||
<h3 className="text-sm font-bold text-[#3D4E4B] tracking-tight">Roles & Permissions</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 tracking-tight mb-2">Assigned Roles</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{viewUser.roles?.length ? viewUser.roles.map((role: any) => (
|
||||
<span key={role.name || role} className="px-3 py-1.5 text-xs font-bold bg-[#3D4E4B] text-white rounded-lg">
|
||||
{role.name || role}
|
||||
</span>
|
||||
)) : <span className="text-sm text-gray-400 font-semibold">No roles assigned</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 tracking-tight mb-2">Granted Permissions</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
||||
{viewUser.permissions?.length ? viewUser.permissions.map((p: any) => (
|
||||
<div key={p.name || p} className="flex items-center gap-2 text-xs text-gray-600 font-semibold bg-gray-50 border border-gray-100 px-2.5 py-1.5 rounded-lg">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[#21A59F] shrink-0" />
|
||||
{p.name || p}
|
||||
</div>
|
||||
)) : <span className="text-sm text-gray-400 font-semibold col-span-full">Inherited from roles</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-50 bg-gray-50/30">
|
||||
<h3 className="text-sm font-bold text-[#3D4E4B] tracking-tight">Activity Timeline</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-[#E3EBE8] flex items-center justify-center shrink-0">
|
||||
<svg className="w-4 h-4 text-[#3D4E4B]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#3D4E4B] tracking-tight">Account Created</p>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">
|
||||
{new Date(viewUser.created_at).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-[#E3EBE8] flex items-center justify-center shrink-0">
|
||||
<svg className="w-4 h-4 text-[#3D4E4B]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#3D4E4B] tracking-tight">Last Updated</p>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">
|
||||
{new Date(viewUser.updated_at).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Link, Head, usePage } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types';
|
||||
import React from 'react';
|
||||
|
||||
export default function Welcome({ auth }: PageProps) {
|
||||
const { system_settings } = usePage<PageProps>().props as any;
|
||||
const appName = system_settings?.app_name || 'biiproject kit v2';
|
||||
const appLogo = system_settings?.app_logo;
|
||||
const appLogoText = system_settings?.app_logo_text || 'BK';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#E3EBE8] text-[#3D4E4B] selection:bg-[#D4A017] selection:text-white font-sans">
|
||||
<Head title={`${appName} — Premium Enterprise Identity`} />
|
||||
|
||||
{/* Navigation - Flat & Professional */}
|
||||
<nav className="fixed w-full z-50 px-6 py-6 anim-down">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between bg-white/80 backdrop-blur-md rounded-[2rem] px-8 py-4 border border-white/20 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl font-bold overflow-hidden border border-gray-100 ${!appLogo ? 'bg-[#3D4E4B] text-[#D4A017]' : 'bg-white'}`}>
|
||||
{appLogo ? <img src={appLogo} className="w-full h-full object-contain" /> : appLogoText}
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight">{appName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
{['Solutions', 'Ecosystem', 'Xxx', 'Governance', 'Intelligence'].map(item => (
|
||||
<Link
|
||||
key={item}
|
||||
href={item === 'Xxx' ? route('xxx') : '#'}
|
||||
className="text-sm font-bold tracking-tight hover:text-[#D4A017] transition-colors"
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-6 w-[1px] bg-gray-100 mx-2" />
|
||||
{auth.user ? (
|
||||
<Link href={route('dashboard')} className="px-6 py-2.5 bg-[#3D4E4B] text-white text-sm font-bold tracking-tight rounded-xl hover:bg-[#2D3A38] transition-all">Command Center</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('login')} className="text-sm font-bold tracking-tight hover:text-[#D4A017] transition-colors">Access</Link>
|
||||
<Link href={route('register')} className="px-6 py-2.5 bg-[#D4A017] text-white text-sm font-bold tracking-tight rounded-xl hover:bg-[#B88B14] transition-all">Initialize Identity</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-40 pb-20 px-6 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-20 items-center">
|
||||
<div className="relative z-10 anim-left">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white rounded-full border border-gray-100 mb-8">
|
||||
<span className="flex h-2 w-2 rounded-full bg-[#D4A017]" />
|
||||
<span className="text-xs font-bold tracking-tight text-[#D4A017]">Enterprise V4.0 Live</span>
|
||||
</div>
|
||||
<h1 className="text-6xl lg:text-8xl font-bold text-[#3D4E4B] tracking-tighter leading-[0.9] mb-8">
|
||||
Precision <br />
|
||||
<span className="text-transparent" style={{ WebkitTextStroke: '1px #3D4E4B' }}>Identity</span> <br />
|
||||
Architecture.
|
||||
</h1>
|
||||
<p className="text-lg text-gray-500 font-medium max-w-md leading-relaxed mb-10">
|
||||
The ultimate governance framework for airline-grade digital ecosystems. Secure, modular, and uncompromisingly professional.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-8">
|
||||
<Link
|
||||
href={auth.user ? route('dashboard') : route('register')}
|
||||
className="px-10 py-5 bg-[#3D4E4B] text-white text-sm font-bold tracking-tight rounded-2xl hover:bg-[#2D3A38] transition-all shadow-2xl shadow-[#3D4E4B]/20 hover:shadow-[#D4A017]/10 flex items-center gap-3 group border border-[#3D4E4B]"
|
||||
>
|
||||
{auth.user ? 'Access Command' : 'Initialize Session'}
|
||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" /></svg>
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<div className="flex -space-x-3">
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="w-10 h-10 rounded-full border-4 border-[#E3EBE8] bg-[#3D4E4B] flex items-center justify-center text-sm font-bold text-white ring-1 ring-white/10">
|
||||
{String.fromCharCode(64 + i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pl-6 flex flex-col justify-center">
|
||||
<span className="text-sm font-bold tracking-tight text-[#3D4E4B]">2,400+ Entities</span>
|
||||
<span className="text-sm font-semibold tracking-tight text-gray-400 mt-0.5">Authenticated Weekly</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative anim-right">
|
||||
<div className="aspect-square bg-white rounded-[3rem] border border-gray-100 shadow-2xl relative overflow-hidden p-8 group">
|
||||
<div className="absolute inset-0 bg-[#3D4E4B] opacity-0 group-hover:opacity-[0.02] transition-opacity" />
|
||||
<div className="w-full h-full border-2 border-dashed border-gray-100 rounded-[2rem] flex flex-col items-center justify-center relative">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-[#D4A017]/10 rounded-full blur-[80px]" />
|
||||
<div className="text-[120px] font-bold text-[#3D4E4B] opacity-5 select-none tracking-tight">SYSTEM</div>
|
||||
<div className="absolute inset-x-8 bottom-8 h-32 bg-gray-50 rounded-2xl border border-gray-100 p-6 flex items-end justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="w-24 h-2 bg-gray-200 rounded-full" />
|
||||
<div className="w-16 h-2 bg-gray-200 rounded-full opacity-50" />
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-[#3D4E4B] rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature Grid */}
|
||||
<section className="py-20 px-6 bg-white border-y border-gray-100">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-20 anim-up">
|
||||
<h2 className="text-sm font-bold tracking-tight text-[#D4A017] mb-4">Core Ecosystem</h2>
|
||||
<h3 className="text-4xl font-bold text-[#3D4E4B] tracking-tight">Governance Intelligence.</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{ t: 'Secure Matrix', d: 'Advanced role-based access control with granular permission governance.', c: '#3D4E4B' },
|
||||
{ t: 'Live Telemetry', d: 'Real-time activity monitoring and audit logging for complete transparency.', c: '#D4A017' },
|
||||
{ t: 'Global Identity', d: 'Multi-provider authentication and identity verification systems.', c: '#21A59F' }
|
||||
].map((f, i) => (
|
||||
<div key={i} className="p-10 rounded-[2.5rem] bg-[#E3EBE8]/30 border border-gray-100 hover:bg-white hover:shadow-xl hover:-translate-y-2 transition-all duration-500 group anim-up" style={{ animationDelay: `${i * 0.1}s` }}>
|
||||
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center text-white mb-8 group-hover:scale-110 transition-transform`} style={{ backgroundColor: f.c }}>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
</div>
|
||||
<h4 className="text-sm font-bold tracking-tight text-[#3D4E4B] mb-4">{f.t}</h4>
|
||||
<p className="text-sm font-medium text-gray-400 leading-relaxed tracking-tight">{f.d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-6 border-t border-gray-100">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-3 anim-left">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold border border-gray-100 ${!appLogo ? 'bg-[#3D4E4B] text-[#D4A017]' : 'bg-white'}`}>
|
||||
{appLogo ? <img src={appLogo} className="w-full h-full object-contain" /> : appLogoText}
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight">{appName} © 2024</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 text-xs font-bold tracking-tight text-gray-400 anim-right">
|
||||
<a href="#" className="hover:text-[#3D4E4B]">Term of Service</a>
|
||||
<a href="#" className="hover:text-[#3D4E4B]">Privacy Protocol</a>
|
||||
<a href="#" className="hover:text-[#3D4E4B]">Compliance</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types';
|
||||
import React from 'react';
|
||||
|
||||
// Minimal page template following existing patterns
|
||||
export default function Xxx({ auth }: PageProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#E3EBE8] text-[#3D4E4B] font-sans">
|
||||
<Head title="Xxx" />
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
<h1 className="text-3xl font-bold mb-8">Xxx Page</h1>
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<p>This is the new xxx page content.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user