feat: inisialisasi project kit v2

This commit is contained in:
2026-05-21 15:57:29 +07:00
commit d4fd478e1f
271 changed files with 35300 additions and 0 deletions
+295
View File
@@ -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>
);
}
+190
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+287
View File
@@ -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>
);
}
+610
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+201
View File
@@ -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>
);
}
+205
View File
@@ -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>
);
}
+236
View File
@@ -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>
);
}
+458
View File
@@ -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>
);
}
+368
View File
@@ -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>
);
}
+222
View File
@@ -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>
);
}
+418
View File
@@ -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>
);
}
+160
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}