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
+278
View File
@@ -0,0 +1,278 @@
/* resources/css/app.css */
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700;800&display=swap');
@import "tailwindcss";
@config "../../tailwind.config.js";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Brand Palette (Based on Image) */
--color-page-bg: #E3EBE8;
--color-sidebar-bg: #3D4E4B;
--color-accent-gold: #D4A017;
--color-accent-teal: #21A59F;
/* Dark Mode Adjustments */
--color-dark-bg: #1A2120;
--color-dark-card: #ffffff;
--color-dark-border: #f3f4f6;
--color-dark-text: #E3EBE8;
--color-primary-500: #D4A017;
--color-primary-600: #B88B14;
/* Sidebar Specific */
--color-sidebar-text: #E3EBE8;
--color-sidebar-active-bg: #E3EBE8;
--color-sidebar-active-text: #3D4E4B;
/* Typography */
--font-sans: 'Lexend', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Lexend', sans-serif;
/* Premium Radius */
--radius-xs: 0.25rem;
--radius-sm: 0.5rem;
--radius-md: 1rem;
--radius-lg: 1.5rem;
--radius-xl: 2rem;
--radius-2xl: 3rem;
/* Shadows */
--shadow-card: 0 10px 40px oklch(23% 0.05 180 / 0.04), 0 2px 8px oklch(23% 0.05 180 / 0.02);
--shadow-modal: 0 30px 100px oklch(0% 0 0 / 0.15);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
background-color: var(--color-page-bg);
color: #2D3748;
-webkit-font-smoothing: antialiased;
transition: background-color 0.3s ease, color 0.3s ease;
}
.dark body {
background-color: var(--color-dark-bg);
color: var(--color-dark-text);
}
/* Removed global .bg-white override to allow explicit white cards */
/* Removed aggressive overrides to restore theme consistency */
.dark .text-gray-400,
.dark .text-gray-500 {
color: #94a3a8;
}
.dark .text-[#3D4E4B] {
color: var(--color-dark-text);
}
.dark .bg-gray-50,
.dark .bg-gray-50\/30,
.dark .bg-gray-50\/50,
.dark .bg-gray-50\/20 {
background-color: rgba(255, 255, 255, 0.02) !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #3D4E4B22;
border-radius: 999px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3D4E4B44;
}
/* Dashboard Card Variants */
.card-gold {
background-color: var(--color-accent-gold);
color: white;
}
.card-teal {
background-color: var(--color-accent-teal);
color: white;
}
.card-dark {
background-color: var(--color-sidebar-bg);
color: white;
}
.card-white {
background-color: white;
color: #2D3748;
}
/* ─── Smooth Animation System ──────────────────────────────────────────────
Replaces animate.css. All animations use:
- Small translate distances (10-16px) — not 100% like animate.css
- cubic-bezier(0.16, 1, 0.3, 1) — ease-out-expo, natural deceleration
- GPU-composited properties only: opacity + transform
─────────────────────────────────────────────────────────────────────── */
@keyframes _fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes _fade-up {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: none; }
}
@keyframes _fade-down {
from { opacity: 0; transform: translateY(-14px); }
to { opacity: 1; transform: none; }
}
@keyframes _fade-left {
from { opacity: 0; transform: translateX(-18px); }
to { opacity: 1; transform: none; }
}
@keyframes _fade-right {
from { opacity: 0; transform: translateX(18px); }
to { opacity: 1; transform: none; }
}
@keyframes _zoom-in {
from { opacity: 0; transform: scale(0.93); }
to { opacity: 1; transform: none; }
}
@keyframes _shake {
0%,100% { transform: none; }
18% { transform: translateX(-7px); }
36% { transform: translateX(6px); }
54% { transform: translateX(-4px); }
72% { transform: translateX(3px); }
}
@keyframes _page-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
@keyframes _shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.anim-shimmer {
background: linear-gradient(90deg,
rgba(0,0,0,0.03) 25%,
rgba(0,0,0,0.06) 37%,
rgba(0,0,0,0.03) 63%
);
background-size: 200% 100%;
animation: _shimmer 1.4s ease-in-out infinite;
}
.dark .anim-shimmer {
background: linear-gradient(90deg,
rgba(255,255,255,0.03) 25%,
rgba(255,255,255,0.06) 37%,
rgba(255,255,255,0.03) 63%
);
background-size: 200% 100%;
}
.anim-fade { animation: _fade-in 0.55s cubic-bezier(0.16, 1, 0.3, 1) both; }
.anim-up { animation: _fade-up 0.65s cubic-bezier(0.16, 1, 0.3, 1) both; }
.anim-down { animation: _fade-down 0.65s cubic-bezier(0.16, 1, 0.3, 1) both; }
.anim-left { animation: _fade-left 0.65s cubic-bezier(0.16, 1, 0.3, 1) both; }
.anim-right { animation: _fade-right 0.65s cubic-bezier(0.16, 1, 0.3, 1) both; }
.anim-zoom { animation: _zoom-in 0.55s cubic-bezier(0.16, 1, 0.3, 1) both; }
.anim-shake { animation: _shake 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }
.anim-page { animation: _page-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; }
/* Authentication Styling */
.auth-card {
background-color: white;
border-radius: 2.5rem;
box-shadow: 0 30px 60px -12px rgba(61, 78, 75, 0.15), 0 18px 36px -18px rgba(61, 78, 75, 0.1);
}
.auth-input {
width: 100%;
padding: 0.625rem 0.875rem;
border-radius: 0.75rem;
background-color: #ffffff;
border: 1px solid #e5e7eb;
font-size: 0.875rem;
color: #111827;
transition: border-color 0.2s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1);
font-family: var(--font-sans);
}
.auth-input::placeholder {
color: #d1d5db;
}
.auth-input:focus {
border-color: #3D4E4B;
box-shadow: 0 0 0 3px rgba(61, 78, 75, 0.1);
outline: none;
}
/* App-wide input field — same border/shape as auth-input, gold accent on focus */
.input-field {
width: 100%;
padding: 0.625rem 0.875rem;
border-radius: 0.75rem;
background-color: #ffffff;
border: 1px solid #e5e7eb;
font-size: 0.875rem;
font-weight: 600;
color: #111827;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
font-family: var(--font-sans);
outline: none;
}
.dark .input-field {
background-color: #ffffff;
border-color: #e5e7eb;
color: #111827;
}
.input-field::placeholder {
color: #d1d5db;
font-weight: 400;
}
.input-field:focus {
border-color: #D4A017;
box-shadow: 0 0 0 3px rgba(212, 160, 23, 0.12);
}
.input-field.is-error {
border-color: #fca5a5;
background-color: #fef2f2;
}
.dark .input-field.is-error {
background-color: rgba(239, 68, 68, 0.05);
}
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
interface AlertProps {
type?: 'info' | 'danger' | 'warning' | 'success';
title: string;
message: string;
className?: string;
}
export function Alert({ type = 'info', title, message, className = '' }: AlertProps) {
const styles = {
info: 'bg-[#E3EBE8]/50 border-gray-200 text-[#3D4E4B]',
danger: 'bg-red-50 border-red-100 text-red-700',
warning: 'bg-amber-50 border-amber-100 text-amber-800',
success: 'bg-teal-50 border-teal-100 text-teal-800'
};
const icons = {
info: <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>,
danger: <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 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>,
warning: <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 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>,
success: <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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
};
return (
<div className={`p-4 rounded-2xl border flex gap-4 items-start anim-fade font-sans ${styles[type]} ${className}`}>
<div className={`shrink-0 mt-0.5 ${type === 'info' ? 'text-[#D4A017]' : ''}`}>{icons[type]}</div>
<div>
<h4 className="text-sm font-bold tracking-tight leading-tight">{title}</h4>
<p className="text-xs font-semibold mt-1 leading-relaxed opacity-70 tracking-tight">{message}</p>
</div>
</div>
);
}
@@ -0,0 +1,11 @@
export default function ApplicationLogo(props) {
return (
<svg
{...props}
viewBox="0 0 316 316"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z" />
</svg>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { usePage } from '@inertiajs/react';
import React from 'react';
import { PageProps } from '@/types';
interface CanProps {
ability: string | string[];
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function Can({ ability, children, fallback = null }: CanProps) {
const { permissions } = usePage<PageProps>().props.auth;
const abilities = Array.isArray(ability) ? ability : [ability];
const allowed = abilities.some(a => permissions.includes(a));
return allowed ? <>{children}</> : <>{fallback}</>;
}
+12
View File
@@ -0,0 +1,12 @@
export default function Checkbox({ className = '', ...props }) {
return (
<input
{...props}
type="checkbox"
className={
'rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 ' +
className
}
/>
);
}
+20
View File
@@ -0,0 +1,20 @@
export default function DangerButton({
className = '',
disabled,
children,
...props
}) {
return (
<button
{...props}
className={
`inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 active:bg-red-700 ${
disabled && 'opacity-25'
} ` + className
}
disabled={disabled}
>
{children}
</button>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { Link } from '@inertiajs/react';
import { Skeleton } from './Skeleton';
interface Column<T> {
header: string;
accessorKey: keyof T | string;
sortable?: boolean;
cell?: (item: T) => React.ReactNode;
}
interface PaginationMeta {
current_page: number;
last_page: number;
total: number;
per_page: number;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
meta?: PaginationMeta;
links?: any[];
filters?: any;
onFilterChange?: (filters: any) => void;
onSort?: (field: string, direction: 'asc' | 'desc') => void;
onEdit?: (item: T) => void;
onDelete?: (item: T) => void;
onRestore?: (item: T) => void;
onPermanentDelete?: (item: T) => void;
canEdit?: boolean;
canDelete?: boolean;
isLoading?: boolean;
emptyAction?: React.ReactNode;
}
export function DataTable<T extends { id: number | string }>({
data,
columns,
meta,
links,
filters = {},
onSort,
onEdit,
onDelete,
onRestore,
onPermanentDelete,
canEdit = false,
canDelete = false,
isLoading = false,
emptyAction,
selectedIds = [],
onSelectionChange,
}: DataTableProps<T> & {
selectedIds?: (number | string)[],
onSelectionChange?: (ids: (number | string)[]) => void
}) {
const toggleAll = () => {
if (!onSelectionChange) return;
if (selectedIds.length === data.length) {
onSelectionChange([]);
} else {
onSelectionChange(data.map(item => item.id));
}
};
const toggleOne = (id: number | string) => {
if (!onSelectionChange) return;
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter(i => i !== id));
} else {
onSelectionChange([...selectedIds, id]);
}
};
const handleSort = (field: string) => {
if (!onSort) return;
const direction = filters.sort_field === field && filters.sort_direction === 'asc' ? 'desc' : 'asc';
onSort(field, direction);
};
return (
<div className="space-y-4">
<div className="relative bg-white rounded-2xl border border-gray-100 shadow-sm transition-all overflow-hidden">
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50/50 border-b border-gray-50">
{onSelectionChange && (
<th className="pl-8 py-5 w-10">
<div className="flex items-center">
<input
type="checkbox"
checked={data.length > 0 && selectedIds.length === data.length}
onChange={toggleAll}
className="w-4 h-4 rounded-lg border-gray-200 text-[#D4A017] focus:ring-[#D4A017]/20 transition-all cursor-pointer"
/>
</div>
</th>
)}
{columns.map((col, idx) => (
<th
key={idx}
className={`px-8 py-5 text-sm font-bold text-[#3D4E4B]/40 tracking-tight whitespace-nowrap ${idx === 0 && !onSelectionChange ? 'pl-8' : ''} ${col.sortable ? 'cursor-pointer hover:text-[#D4A017] transition-colors group' : ''}`}
onClick={() => col.sortable && handleSort(col.accessorKey as string)}
>
<div className="flex items-center gap-2">
{col.header}
{col.sortable && (
<div className="flex flex-col opacity-20 group-hover:opacity-100 transition-opacity scale-75">
<svg className={`w-3 h-3 ${filters.sort_field === col.accessorKey && filters.sort_direction === 'asc' ? 'text-[#D4A017]' : ''}`} fill="currentColor" viewBox="0 0 24 24"><path d="M12 4l-8 8h16z"/></svg>
<svg className={`w-3 h-3 -mt-1.5 ${filters.sort_field === col.accessorKey && filters.sort_direction === 'desc' ? 'text-[#D4A017]' : ''}`} fill="currentColor" viewBox="0 0 24 24"><path d="M12 20l8-8H4z"/></svg>
</div>
)}
</div>
</th>
))}
{(canEdit || onDelete || onRestore || onPermanentDelete) && (
<th className="px-8 py-5 text-right text-sm font-bold text-[#3D4E4B]/40 tracking-tight">Actions</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="anim-fade">
{onSelectionChange && <td className="pl-8 py-5"><Skeleton width={16} height={16} /></td>}
{columns.map((_, idx) => (
<td key={idx} className={`px-8 py-5 ${idx === 0 && !onSelectionChange ? 'pl-8' : ''}`}>
<Skeleton variant="text" width={idx === 0 ? '60%' : '80%'} />
</td>
))}
{(canEdit || onDelete || onRestore || onPermanentDelete) && (
<td className="px-8 py-5 text-right"><Skeleton className="ml-auto" width={80} height={32} /></td>
)}
</tr>
))
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length + (onSelectionChange ? 1 : 0) + 1} className="px-8 py-12 text-center">
<div className="flex flex-col items-center gap-3 anim-up">
<div className="w-12 h-12 rounded-2xl bg-gray-50 flex items-center justify-center text-gray-300">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0a2 2 0 01-2 2H6a2 2 0 01-2-2m16 0l-8 4-8-4" /></svg>
</div>
<p className="text-sm font-bold text-gray-400">No data found</p>
{emptyAction && <div className="mt-1">{emptyAction}</div>}
</div>
</td>
</tr>
) : data.map((item) => (
<tr key={item.id} className={`hover:bg-gray-50/30 transition-colors group ${selectedIds.includes(item.id) ? 'bg-[#D4A017]/5' : ''}`}>
{onSelectionChange && (
<td className="pl-8 py-5">
<input
type="checkbox"
checked={selectedIds.includes(item.id)}
onChange={() => toggleOne(item.id)}
className="w-4 h-4 rounded-lg border-gray-200 text-[#D4A017] focus:ring-[#D4A017]/20 transition-all cursor-pointer"
/>
</td>
)}
{columns.map((col, colIndex) => (
<td key={colIndex} className={`px-8 py-5 whitespace-nowrap ${colIndex === 0 && !onSelectionChange ? 'pl-8' : ''}`}>
{col.cell ? col.cell(item) : (
<span className="text-xs font-bold text-[#3D4E4B]">{String(item[col.accessorKey as keyof T] || '-')}</span>
)}
</td>
))}
{(canEdit || onDelete || onRestore || onPermanentDelete) && (
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all translate-x-2 group-hover:translate-x-0">
{onRestore && (
<button onClick={() => onRestore(item)} className="p-2 rounded-xl text-gray-400 hover:text-[#21A59F] hover:bg-[#21A59F]/5 transition-all" title="Restore">
<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>
</button>
)}
{canEdit && onEdit && (
<button onClick={() => onEdit(item)} className="p-2 rounded-xl text-gray-400 hover:text-[#D4A017] hover:bg-[#D4A017]/5 transition-all" title="Edit">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</button>
)}
{onDelete && (
<button onClick={() => onDelete(item)} className="p-2 rounded-xl text-gray-400 hover:text-red-500 hover:bg-red-50 transition-all" title="Archive">
<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>
)}
{onPermanentDelete && (
<button onClick={() => onPermanentDelete(item)} className="p-2 rounded-xl text-red-300 hover:text-red-600 hover:bg-red-50 transition-all" title="Delete permanently">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path 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>
</button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination - Professional Flat Style */}
{meta && links && (
<div className="flex items-center justify-between px-2">
<div className="text-sm font-bold text-[#3D4E4B]/40 tracking-tight">
Showing <span className="text-[#3D4E4B]">{data.length}</span> of <span className="text-[#3D4E4B]">{meta.total}</span> entries
</div>
<div className="flex items-center gap-1.5">
{links.map((link, idx) => {
if (link.label.includes('Previous')) {
return (
<Link key={idx} href={link.url || '#'} preserveState className={`px-4 py-2 rounded-xl text-sm font-bold tracking-tight transition-all ${!link.url ? 'text-gray-300 pointer-events-none' : 'text-[#3D4E4B] hover:bg-white hover:shadow-sm'}`}>Prev</Link>
);
}
if (link.label.includes('Next')) {
return (
<Link key={idx} href={link.url || '#'} preserveState className={`px-4 py-2 rounded-xl text-sm font-bold tracking-tight transition-all ${!link.url ? 'text-gray-300 pointer-events-none' : 'text-[#3D4E4B] hover:bg-white hover:shadow-sm'}`}>Next</Link>
);
}
if (link.label.includes('...') || /^\d+$/.test(link.label)) {
return (
<Link
key={idx} href={link.url || '#'} preserveState
className={`w-9 h-9 flex items-center justify-center rounded-xl text-sm font-bold transition-all
${link.active
? 'bg-[#3D4E4B] text-white'
: 'text-[#3D4E4B] hover:bg-white hover:shadow-sm'
}`}
>
{link.label}
</Link>
);
}
return null;
})}
</div>
</div>
)}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { Transition } from '@headlessui/react';
import { Link } from '@inertiajs/react';
import { createContext, useContext, useState } from 'react';
const DropDownContext = createContext();
const Dropdown = ({ children }) => {
const [open, setOpen] = useState(false);
const toggleOpen = () => {
setOpen((previousState) => !previousState);
};
return (
<DropDownContext.Provider value={{ open, setOpen, toggleOpen }}>
<div className="relative">{children}</div>
</DropDownContext.Provider>
);
};
const Trigger = ({ children }) => {
const { open, setOpen, toggleOpen } = useContext(DropDownContext);
return (
<>
<div onClick={toggleOpen}>{children}</div>
{open && (
<div
className="fixed inset-0 z-40"
onClick={() => setOpen(false)}
></div>
)}
</>
);
};
const Content = ({
align = 'right',
width = '48',
contentClasses = 'py-1 bg-white',
children,
}) => {
const { open, setOpen } = useContext(DropDownContext);
let alignmentClasses = 'origin-top';
if (align === 'left') {
alignmentClasses = 'ltr:origin-top-left rtl:origin-top-right start-0';
} else if (align === 'right') {
alignmentClasses = 'ltr:origin-top-right rtl:origin-top-left end-0';
}
let widthClasses = '';
if (width === '48') {
widthClasses = 'w-48';
}
return (
<>
<Transition
show={open}
enter="transition ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className={`absolute z-50 mt-2 rounded-md shadow-lg ${alignmentClasses} ${widthClasses}`}
onClick={() => setOpen(false)}
>
<div
className={
`rounded-md ring-1 ring-black ring-opacity-5 ` +
contentClasses
}
>
{children}
</div>
</div>
</Transition>
</>
);
};
const DropdownLink = ({ className = '', children, ...props }) => {
return (
<Link
{...props}
className={
'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 transition duration-150 ease-in-out hover:bg-gray-100 focus:bg-gray-100 focus:outline-none ' +
className
}
>
{children}
</Link>
);
};
Dropdown.Trigger = Trigger;
Dropdown.Content = Content;
Dropdown.Link = DropdownLink;
export default Dropdown;
+10
View File
@@ -0,0 +1,10 @@
export default function InputError({ message, className = '', ...props }) {
return message ? (
<p
{...props}
className={'text-sm text-red-600 ' + className}
>
{message}
</p>
) : null;
}
+18
View File
@@ -0,0 +1,18 @@
export default function InputLabel({
value,
className = '',
children,
...props
}) {
return (
<label
{...props}
className={
`block text-sm font-medium text-gray-700 ` +
className
}
>
{value ? value : children}
</label>
);
}
+65
View File
@@ -0,0 +1,65 @@
import {
Dialog,
DialogPanel,
Transition,
TransitionChild,
} from '@headlessui/react';
export default function Modal({
children,
show = false,
maxWidth = '2xl',
closeable = true,
onClose = () => {},
}) {
const close = () => {
if (closeable) {
onClose();
}
};
const maxWidthClass = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
}[maxWidth];
return (
<Transition show={show} leave="duration-200">
<Dialog
as="div"
id="modal"
className="fixed inset-0 z-50 flex transform items-center overflow-y-auto px-4 py-6 transition-all sm:px-0"
onClose={close}
>
<TransitionChild
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 bg-gray-500/75" />
</TransitionChild>
<TransitionChild
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel
className={`mb-6 transform overflow-hidden rounded-lg bg-white shadow-xl transition-all sm:mx-auto sm:w-full ${maxWidthClass}`}
>
{children}
</DialogPanel>
</TransitionChild>
</Dialog>
</Transition>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Link } from '@inertiajs/react';
export default function NavLink({
active = false,
className = '',
children,
...props
}) {
return (
<Link
{...props}
className={
'inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none ' +
(active
? 'border-indigo-400 text-gray-900 focus:border-indigo-700'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 focus:border-gray-300 focus:text-gray-700') +
className
}
>
{children}
</Link>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createPortal } from 'react-dom';
export function Portal({ children }: { children: React.ReactNode }) {
return createPortal(children, document.body);
}
+20
View File
@@ -0,0 +1,20 @@
export default function PrimaryButton({
className = '',
disabled,
children,
...props
}) {
return (
<button
{...props}
className={
`inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-bold tracking-tight text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900 ${
disabled && 'opacity-25'
} ` + className
}
disabled={disabled}
>
{children}
</button>
);
}
@@ -0,0 +1,21 @@
import { Link } from '@inertiajs/react';
export default function ResponsiveNavLink({
active = false,
className = '',
children,
...props
}) {
return (
<Link
{...props}
className={`flex w-full items-start border-l-4 py-2 pe-4 ps-3 ${
active
? 'border-indigo-400 bg-indigo-50 text-indigo-700 focus:border-indigo-700 focus:bg-indigo-100 focus:text-indigo-800'
: 'border-transparent text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-800 focus:border-gray-300 focus:bg-gray-50 focus:text-gray-800'
} text-base font-medium transition duration-150 ease-in-out focus:outline-none ${className}`}
>
{children}
</Link>
);
}
@@ -0,0 +1,46 @@
import { router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export function RouterProgress() {
const [progress, setProgress] = useState<number | null>(null);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const offStart = router.on('start', () => {
setProgress(0);
// Animate to ~80% quickly, then slow down while waiting for response
timer = setTimeout(() => setProgress(30), 50);
timer = setTimeout(() => setProgress(60), 300);
timer = setTimeout(() => setProgress(80), 800);
});
const offFinish = router.on('finish', () => {
clearTimeout(timer);
setProgress(100);
// Remove bar after fade-out
setTimeout(() => setProgress(null), 400);
});
return () => { offStart(); offFinish(); clearTimeout(timer); };
}, []);
if (progress === null) return null;
return createPortal(
<div
className="fixed top-0 left-0 z-[9999] h-[3px] pointer-events-none"
style={{
width: `${progress}%`,
background: 'linear-gradient(90deg, #D4A017, #f0c040)',
boxShadow: '0 0 8px rgba(212,160,23,0.6)',
transition: progress === 100
? 'width 0.15s ease-out, opacity 0.3s ease 0.15s'
: 'width 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
opacity: progress === 100 ? 0 : 1,
}}
/>,
document.body
);
}
@@ -0,0 +1,22 @@
export default function SecondaryButton({
type = 'button',
className = '',
disabled,
children,
...props
}) {
return (
<button
{...props}
type={type}
className={
`inline-flex items-center rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-bold tracking-tight text-[#3D4E4B] transition-all hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#D4A017] focus:ring-offset-2 disabled:opacity-25 ${
disabled && 'opacity-25'
} ` + className
}
disabled={disabled}
>
{children}
</button>
);
}
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
interface SkeletonProps {
className?: string;
variant?: 'circle' | 'rect' | 'text';
width?: string | number;
height?: string | number;
}
export function Skeleton({
className = '',
variant = 'rect',
width,
height
}: SkeletonProps) {
const baseClasses = "anim-shimmer rounded";
const variantClasses = {
circle: "rounded-full",
rect: "rounded-lg",
text: "rounded h-4 w-full"
};
const style: React.CSSProperties = {
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
};
return (
<div
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
style={style}
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
export default forwardRef(function TextInput(
{ type = 'text', className = '', isFocused = false, ...props },
ref,
) {
const localRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => localRef.current?.focus(),
}));
useEffect(() => {
if (isFocused) {
localRef.current?.focus();
}
}, [isFocused]);
return (
<input
{...props}
type={type}
className={
'rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 ' +
className
}
ref={localRef}
/>
);
});
+80
View File
@@ -0,0 +1,80 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
id: number;
message: string;
type: ToastType;
}
interface ToastContextType {
showToast: (message: string, type?: ToastType) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function useToast() {
const context = useContext(ToastContext);
if (!context) throw new Error('useToast must be used within a ToastProvider');
return context;
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType = 'success') => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, 3000);
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<ToastContainer toasts={toasts} />
</ToastContext.Provider>
);
}
function ToastContainer({ toasts }: { toasts: Toast[] }) {
if (typeof document === 'undefined') return null;
return createPortal(
<div className="fixed bottom-8 right-8 z-[100] flex flex-col gap-3 pointer-events-none">
{toasts.map(toast => (
<ToastItem key={toast.id} toast={toast} />
))}
</div>,
document.body
);
}
function ToastItem({ toast }: { toast: Toast }) {
const icons = {
success: <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>,
error: <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M6 18L18 6M6 6l12 12" /></svg>,
warning: <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path 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>,
info: <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>,
};
const colors = {
success: "bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20",
error: "bg-red-50 text-red-600 border-red-100 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20",
warning: "bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20",
info: "bg-blue-50 text-blue-600 border-blue-100 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20",
};
return (
<div className={`
flex items-center gap-3 px-6 py-4 rounded-2xl border shadow-xl shadow-black/5 pointer-events-auto anim-right
${colors[toast.type]}
`}>
<div className="shrink-0">{icons[toast.type]}</div>
<div className="text-sm font-bold tracking-tight">{toast.message}</div>
</div>
);
}
@@ -0,0 +1,77 @@
import React from 'react';
import { Sidebar } from './components/Sidebar';
import { Topbar } from './components/Topbar';
import { usePage } from '@inertiajs/react';
interface Props {
children: React.ReactNode;
}
export default function AuthenticatedLayout({ children }: Props) {
const { system_settings } = usePage().props as any;
const primaryColor = system_settings?.primary_color;
const [theme, setTheme] = React.useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('theme') || 'light';
}
return 'light';
});
const [sidebarOpen, setSidebarOpen] = React.useState(false);
React.useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
// Close sidebar on route change (mobile)
const url = usePage().url;
React.useEffect(() => {
setSidebarOpen(false);
}, [url]);
return (
<div className="flex h-screen overflow-hidden transition-colors duration-300 bg-[#E3EBE8] dark:bg-[#1A2120]"
style={primaryColor ? {
'--color-primary-500': primaryColor,
'--color-accent-gold': primaryColor
} as React.CSSProperties : {}}>
{/* Mobile overlay */}
<div
onClick={() => setSidebarOpen(false)}
className={`fixed inset-0 z-10 bg-black/40 backdrop-blur-sm lg:hidden transition-opacity duration-300
${sidebarOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
/>
{/* Sidebar — desktop: always visible in flow; mobile: fixed slide-in */}
<div className="hidden lg:block shrink-0">
<Sidebar theme={theme} />
</div>
<div className={`fixed inset-y-0 left-0 z-20 lg:hidden transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<Sidebar theme={theme} />
</div>
<div className="flex-1 flex flex-col min-w-0 h-full relative">
<Topbar
theme={theme}
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
onMenuToggle={() => setSidebarOpen(o => !o)}
sidebarOpen={sidebarOpen}
/>
<main className="flex-1 overflow-y-auto px-6 pt-6 pb-6 custom-scrollbar">
<div key={url} className="max-w-[1600px] mx-auto anim-page">
{children}
</div>
</main>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import { Link, usePage } from '@inertiajs/react';
export default function GuestLayout({ children }: { children: React.ReactNode }) {
const props = usePage().props as any;
const { system_settings } = props;
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 flex font-sans">
{/* ── Left brand panel ─────────────────────────────────────────── */}
<div className="hidden lg:flex lg:w-[44%] bg-[#3D4E4B] flex-col justify-between p-14 relative overflow-hidden shrink-0">
{/* Dot-grid texture */}
<svg className="absolute inset-0 w-full h-full pointer-events-none" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dots" x="0" y="0" width="28" height="28" patternUnits="userSpaceOnUse">
<circle cx="1.5" cy="1.5" r="1.5" fill="white" fillOpacity="0.07" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#dots)" />
</svg>
{/* Decorative rings */}
<div className="absolute -bottom-40 -right-40 w-[480px] h-[480px] rounded-full border border-white/[0.06] pointer-events-none" />
<div className="absolute -bottom-64 -right-64 w-[700px] h-[700px] rounded-full border border-white/[0.04] pointer-events-none" />
<div className="absolute top-[-60px] left-[-60px] w-[300px] h-[300px] rounded-full border border-white/[0.04] pointer-events-none" />
{/* Logo */}
<div className="relative z-10 anim-fade">
<Link href="/" className="inline-flex items-center gap-3 group">
<div className={`w-10 h-10 rounded-[0.75rem] flex items-center justify-center overflow-hidden text-sm font-bold text-white shrink-0 ${!appLogo ? 'bg-[#D4A017]' : 'bg-white/10'}`}>
{appLogo
? <img src={appLogo} alt={appName} className="w-full h-full object-contain" />
: appLogoText
}
</div>
<span className="text-white font-bold text-base tracking-tight">{appName}</span>
</Link>
</div>
{/* Main copy */}
<div className="relative z-10 anim-up" style={{ animationDelay: '0.1s' }}>
<p className="text-[#D4A017] text-xs font-bold uppercase tracking-[0.18em] mb-5">Enterprise Platform</p>
<h2 className="text-white text-[2rem] font-bold leading-[1.2] tracking-tight">
Manage your<br />organization<br />with precision.
</h2>
<p className="mt-5 text-[#E3EBE8]/45 text-sm leading-relaxed max-w-xs">
Access control, user management, and system configuration unified in one secure interface.
</p>
{/* Feature pills */}
<div className="mt-9 flex flex-col gap-3">
{[
'Role-based access control',
'Real-time audit logs',
'Multi-level permissions',
].map((feat) => (
<div key={feat} className="flex items-center gap-3">
<div className="w-1.5 h-1.5 rounded-full bg-[#D4A017] shrink-0" />
<span className="text-[#E3EBE8]/55 text-sm font-medium">{feat}</span>
</div>
))}
</div>
</div>
{/* Footer */}
<div className="relative z-10 anim-fade" style={{ animationDelay: '0.2s' }}>
<p className="text-[#E3EBE8]/25 text-xs">© {new Date().getFullYear()} {appName}. All rights reserved.</p>
</div>
</div>
{/* ── Right form panel ─────────────────────────────────────────── */}
<div className="flex-1 flex flex-col items-center justify-center bg-white px-8 py-12 min-h-screen">
{/* Mobile-only logo */}
<div className="lg:hidden mb-10 flex items-center gap-3 anim-down">
<div className={`w-9 h-9 rounded-[0.6rem] flex items-center justify-center text-sm font-bold overflow-hidden ${!appLogo ? 'bg-[#3D4E4B] text-white' : ''}`}>
{appLogo
? <img src={appLogo} alt={appName} className="w-full h-full object-contain" />
: appLogoText
}
</div>
<span className="text-[#3D4E4B] font-bold text-base tracking-tight">{appName}</span>
</div>
{/* Form slot */}
<div className="w-full max-w-[360px]">
{children}
</div>
{/* Back link */}
<div className="mt-10 anim-fade" style={{ animationDelay: '0.35s' }}>
<Link
href="/"
className="inline-flex items-center gap-1.5 text-xs font-semibold text-gray-300 hover:text-[#3D4E4B] transition-colors duration-200 tracking-tight"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to home
</Link>
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { Link } from '@inertiajs/react';
import React from 'react';
import { Can } from '@/Components/Can';
interface NavigationItemProps {
href: string;
active: boolean;
icon: React.ReactNode;
label: string;
ability?: string | string[];
}
export function NavigationItem({ href, active, icon, label, ability }: NavigationItemProps) {
const content = (
<Link
href={href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
active
? 'bg-[var(--color-primary-50)] text-[var(--color-primary-600)]'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<div className="w-5 h-5 flex items-center justify-center">
{icon}
</div>
<span className="font-medium hidden md:block">{label}</span>
</Link>
);
if (ability) {
return <Can ability={ability}>{content}</Can>;
}
return content;
}
+151
View File
@@ -0,0 +1,151 @@
import { usePage, Link } from '@inertiajs/react';
import { PageProps } from '@/types';
export function Sidebar({ theme: _theme }: { theme: string }) {
const props = usePage<PageProps>().props as any;
const { auth } = props;
const { user, permissions } = auth;
const path = window.location.pathname;
const canAccess = (ability: string | null) => {
if (!ability) return true;
return permissions.includes(ability);
};
const initials = `${user.first_name?.charAt(0) || ''}${user.last_name?.charAt(0) || ''}`.toUpperCase();
const allGroups = [
{
label: 'Governance',
items: [
{
href: '/dashboard',
label: 'Dashboard',
ability: null,
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 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>
},
{
href: '/notifications',
label: 'Notifications',
ability: 'role.manage',
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 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>
},
]
},
{
label: 'Management',
items: [
{
href: '/users',
label: 'Users',
ability: 'user.view',
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 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
},
{
href: '/roles',
label: 'Roles',
ability: 'role.view',
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>
},
{
href: '/activity-logs',
label: 'Activity Logs',
ability: 'user.view',
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
},
]
},
{
label: 'Account',
items: [
{
href: '/settings',
label: 'Settings',
ability: null,
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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
},
]
},
{
label: 'Systems',
adminOnly: true,
items: [
{
href: '/system-settings',
label: 'System Settings',
ability: null,
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>
},
{
href: '/documentation',
label: 'Dokumentasi',
ability: null,
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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>
},
]
},
];
const canManageSettings = permissions.includes('settings.manage');
return (
<aside className="w-[260px] h-screen p-4 shrink-0 relative z-20 flex flex-col anim-left">
<div className="bg-[#3D4E4B] flex-1 rounded-[2rem] flex flex-col relative">
{/* Profile Section */}
<div className="p-8 flex flex-col items-center text-center">
<div className="relative mb-4">
<div className="w-16 h-16 rounded-full border border-[#D4A017] p-1 flex items-center justify-center">
<div className="w-full h-full rounded-full bg-[#2D3A38] overflow-hidden flex items-center justify-center font-bold text-white/40 text-lg">
{user.avatar_url ? (
<img src={user.avatar_url} className="w-full h-full object-cover" />
) : initials}
</div>
</div>
<div className="absolute bottom-0 right-0 w-4 h-4 bg-[#21A59F] border-2 border-[#3D4E4B] rounded-full"></div>
</div>
<h3 className="text-white text-sm font-bold tracking-tight">{user.first_name} {user.last_name}</h3>
<p className="text-[#E3EBE8]/40 text-sm font-medium mt-0.5">{user.email}</p>
</div>
{/* Nav Section with Grouping */}
<nav className="flex-1 px-3 space-y-6 overflow-y-auto custom-scrollbar pb-10">
{allGroups.map((group) => {
if (group.adminOnly && !canManageSettings) return null;
const items = group.items.filter(i => canAccess(i.ability));
if (items.length === 0) return null;
return (
<div key={group.label} className="space-y-1.5">
<div className="px-5 text-xs font-semibold text-[#E3EBE8]/30 tracking-widest uppercase mb-2">{group.label}</div>
{items.map((item) => {
const isActive = path === item.href || path.startsWith(item.href + '/');
return (
<Link key={item.href} href={item.href}
className={`flex items-center gap-4 py-3 transition-[color,background-color,box-shadow,opacity] duration-200 relative group
${isActive
? 'text-[#3D4E4B] bg-[#E3EBE8] dark:bg-[#1A2120] dark:text-white -mr-3 pr-6 pl-5 rounded-l-[15px] w-[calc(100%+0.75rem)] shadow-sm'
: 'text-[#E3EBE8]/60 hover:text-white hover:bg-white/5 px-5 rounded-[15px]'}`}>
<span className={`shrink-0 z-10 transition-colors duration-300 ${isActive ? 'text-[#D4A017]' : 'group-hover:text-[#D4A017]'}`}>
{item.icon}
</span>
<span className="text-sm font-semibold tracking-tight z-10">{item.label}</span>
</Link>
);
})}
</div>
);
})}
</nav>
{/* Footer Section */}
<div className="p-6 pb-8">
<div className="px-4 py-3 rounded-2xl bg-white/5 border border-white/5 flex items-center justify-between group hover:bg-white/10 transition-colors cursor-default">
<span className="text-sm font-semibold tracking-tight text-[#D4A017]">biiproject kit</span>
<span className="text-sm font-bold text-white">v2</span>
</div>
</div>
</div>
</aside>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { usePage, Link, router } from '@inertiajs/react';
import React, { useState, useRef, useEffect } from 'react';
import { PageProps } from '@/types';
export function Topbar({ theme, onThemeToggle, onMenuToggle, sidebarOpen }: { theme: string; onThemeToggle: () => void; onMenuToggle: () => void; sidebarOpen: boolean }) {
const props = usePage<PageProps>().props as any;
const { auth, system_settings, unread_notifications } = props;
const { user, roles } = auth;
const [showDropdown, setShowDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const path = window.location.pathname;
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDropdown(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Global Search Logic
useEffect(() => {
if (searchQuery.length < 2) {
setSearchResults([]);
return;
}
const timer = setTimeout(async () => {
setIsSearching(true);
try {
const response = await fetch(`/api/search?query=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
setSearchResults(data);
} catch (error) {
console.error('Search error:', error);
} finally {
setIsSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
// Keyboard Shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const handleLogout = () => {
router.post('/logout');
};
const initials = `${user?.first_name?.charAt(0) || ''}${user?.last_name?.charAt(0) || ''}`.toUpperCase();
const segments = path.split('/').filter(Boolean);
const formatSegment = (s: string) =>
s.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const pageTitle = segments.length > 0 ? formatSegment(segments[0]) : 'Dashboard';
const breadcrumbs = segments.length > 1
? segments.map((s, i) => ({ label: formatSegment(s), href: '/' + segments.slice(0, i + 1).join('/') }))
: null;
return (
<header className="h-16 flex items-center justify-between px-6 shrink-0 relative z-30 transition-colors duration-300 bg-transparent">
{/* Left: Breadcrumb area */}
<div className="flex items-center gap-6">
{/* Burger — mobile only */}
<button
onClick={onMenuToggle}
className="lg:hidden p-2 rounded-xl bg-white dark:bg-white/5 border border-gray-100 dark:border-white/5 text-gray-500 dark:text-gray-400 hover:text-[#3D4E4B] dark:hover:text-white transition-all"
aria-label="Toggle menu"
>
{sidebarOpen ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<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 12h16M4 18h16" />
</svg>
)}
</button>
<div className="hidden lg:block">
<h2 className="text-sm font-bold text-[#3D4E4B] dark:text-white tracking-tight leading-none">{pageTitle}</h2>
<div className="flex items-center gap-1.5 text-xs font-semibold text-gray-400 tracking-tight mt-1">
<Link href="/dashboard" className="hover:text-[#3D4E4B] dark:hover:text-white transition-colors">
{system_settings?.app_name || 'biiproject kit'}
</Link>
{breadcrumbs ? breadcrumbs.map((crumb, i) => (
<React.Fragment key={crumb.href}>
<svg className="w-1 h-1 shrink-0" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" /></svg>
{i === breadcrumbs.length - 1
? <span className="text-[#D4A017] font-bold">{crumb.label}</span>
: <Link href={crumb.href} className="hover:text-[#3D4E4B] dark:hover:text-white transition-colors">{crumb.label}</Link>
}
</React.Fragment>
)) : (
<>
<svg className="w-1 h-1 shrink-0" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" /></svg>
<span className="text-[#D4A017] font-bold">{pageTitle}</span>
</>
)}
</div>
</div>
{/* Global Search */}
<div className="relative group ml-4">
<div className="relative w-[300px]">
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none group-focus-within:text-[#D4A017] transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<input
ref={searchInputRef}
type="text"
placeholder="Search anything... (Ctrl+K)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-10 pl-10 pr-4 rounded-xl border border-gray-100 dark:border-white/5 bg-white dark:bg-white/5 text-xs font-bold text-[#3D4E4B] dark:text-white placeholder-gray-400 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all"
/>
{isSearching && (
<div className="absolute right-3.5 top-1/2 -translate-y-1/2">
<div className="w-3.5 h-3.5 border-2 border-[#D4A017] border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
{/* Search Results Dropdown */}
{searchResults.length > 0 && (
<div className="absolute left-0 right-0 mt-3 bg-white dark:bg-[#232D2B] rounded-2xl shadow-2xl border border-gray-100 dark:border-white/5 overflow-hidden anim-up z-50">
<div className="p-2 max-h-[400px] overflow-y-auto custom-scrollbar">
{searchResults.map((result, idx) => (
<Link
key={idx}
href={result.url}
onClick={() => setSearchQuery('')}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-all group"
>
<div className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-white/5 flex items-center justify-center text-gray-400 group-hover:text-[#D4A017] transition-colors shrink-0">
{result.icon === 'user' && <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>}
{result.icon === 'shield' && <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path 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>}
{result.icon === 'clock' && <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>}
</div>
<div>
<div className="text-xs font-bold text-[#3D4E4B] dark:text-white tracking-tight">{result.title}</div>
<div className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-0.5">{result.type} {result.subtitle}</div>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
{/* Right: Actions Area */}
<div className="flex items-center gap-3">
{/* Theme Toggle */}
<button
onClick={onThemeToggle}
className="p-2.5 rounded-xl bg-white dark:bg-white/5 text-gray-400 hover:text-[#D4A017] transition-all border border-gray-100 dark:border-white/5"
title={`Switch to ${theme === 'dark' ? 'Light' : 'Dark'} Mode`}
>
{theme === 'dark' ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707.707M12 5a7 7 0 100 14 7 7 0 000-14z" /></svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
)}
</button>
<Link href="/notifications" className="relative p-2.5 rounded-xl bg-white dark:bg-white/5 text-gray-400 hover:text-[#3D4E4B] dark:hover:text-white transition-all border border-gray-100 dark:border-white/5 flex items-center justify-center">
<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 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>
{unread_notifications > 0 && (
<span className="absolute top-1.5 right-1.5 min-w-[1rem] h-4 px-0.5 bg-red-500 rounded-full flex items-center justify-center text-[9px] font-black text-white leading-none">
{unread_notifications > 99 ? '99+' : unread_notifications}
</span>
)}
</Link>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center gap-3 p-1.5 rounded-xl bg-white dark:bg-white/5 border border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10 transition-all group"
>
<div className="w-8 h-8 rounded-lg bg-[#3D4E4B] flex items-center justify-center font-bold text-white text-sm">
{user?.avatar_url ? (
<img src={user.avatar_url} className="w-full h-full object-cover rounded-lg" />
) : (
initials
)}
</div>
<div className="hidden sm:block text-left pr-2">
<div className="text-xs font-bold text-[#3D4E4B] dark:text-white leading-none">{user?.first_name}</div>
<div className="text-[10px] text-[#D4A017] font-black uppercase tracking-widest mt-1">{roles?.[0]?.replace('-', ' ') || 'User'}</div>
</div>
</button>
{showDropdown && (
<div className="absolute right-0 mt-3 w-56 bg-white dark:bg-[#232D2B] rounded-2xl shadow-2xl border border-gray-50 dark:border-white/5 py-2 z-50 anim-up">
<div className="px-5 py-3 border-b border-gray-50 dark:border-white/5 mb-1">
<div className="text-xs font-black text-[#3D4E4B] dark:text-white tracking-tight">{user?.first_name} {user?.last_name}</div>
<div className="text-[10px] text-gray-400 font-bold truncate mt-1 uppercase tracking-widest">{user?.email}</div>
</div>
<Link href="/profile" className="flex items-center gap-2.5 px-5 py-3 text-xs font-bold text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 transition-all">
<svg className="w-4 h-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
Account Settings
</Link>
<button onClick={handleLogout} className="w-full flex items-center gap-2.5 px-5 py-3 text-xs font-bold text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all text-left border-t border-gray-50 dark:border-white/5 mt-1">
<svg className="w-4 h-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
Sign Out Session
</button>
</div>
)}
</div>
</div>
</header>
);
}
+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>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Can } from '../Components/Can';
import React from 'react';
// Mock the Inertia usePage hook
vi.mock('@inertiajs/react', () => ({
usePage: () => ({
props: {
auth: {
permissions: ['user.edit', 'role.view'],
},
},
}),
}));
describe('<Can />', () => {
it('renders children when permission is granted', () => {
render(
<Can ability="user.edit">
<button>Edit Button</button>
</Can>
);
expect(screen.getByRole('button', { name: /edit button/i })).toBeDefined();
});
it('renders fallback when permission is denied', () => {
render(
<Can ability="user.delete" fallback={<span>No Access</span>}>
<button>Delete Button</button>
</Can>
);
expect(screen.getByText('No Access')).toBeDefined();
expect(screen.queryByRole('button')).toBeNull();
});
it('handles array of abilities (OR logic)', () => {
render(
<Can ability={['user.delete', 'role.view']}>
<div>Visible Content</div>
</Can>
);
expect(screen.getByText('Visible Content')).toBeDefined();
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+34
View File
@@ -0,0 +1,34 @@
import '../css/app.css';
import './bootstrap';
import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
import { RouterProgress } from './Components/RouterProgress';
import { ToastProvider } from './Contexts/ToastContext';
const appName = import.meta.env.VITE_APP_NAME || 'biiproject kit v2';
createInertiaApp({
title: (title) => {
// Read appName from global window prop if injected, or default
const pageProps = (window as any).document.getElementById('app')?.dataset.page;
const appName = pageProps ? JSON.parse(pageProps).props?.system_settings?.app_name : 'biiproject kit v2';
return `${title ? title + ' — ' : ''}${appName || 'biiproject kit v2'}`;
},
resolve: (name) =>
resolvePageComponent(
`./Pages/${name}.tsx`,
import.meta.glob('./Pages/**/*.tsx'),
),
setup({ el, App, props }) {
const root = createRoot(el);
root.render(
<ToastProvider>
<RouterProgress />
<App {...props} />
</ToastProvider>
);
},
progress: false,
});
+4
View File
@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+80
View File
@@ -0,0 +1,80 @@
import Swal from 'sweetalert2';
import 'sweetalert2/dist/sweetalert2.min.css';
const Toast = Swal.mixin({
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 3000,
timerProgressBar: true,
});
export const swalSuccess = (title: string, text?: string) => {
return Toast.fire({
icon: 'success',
title,
text,
customClass: { popup: '!rounded-[2rem] font-sans px-6' }
});
};
export const swalError = (title: string, text?: string) => {
return Toast.fire({
icon: 'error',
title,
text,
customClass: { popup: '!rounded-[2rem] font-sans px-6' }
});
};
export const swalConfirm = (title: string, text: string, confirmText: string = 'Yes, proceed') => {
return Swal.fire({
title,
text,
icon: 'warning',
showCancelButton: true,
confirmButtonText: confirmText,
cancelButtonText: 'Cancel',
reverseButtons: true,
buttonsStyling: false,
customClass: {
popup: '!rounded-[3rem] border-none shadow-2xl font-sans p-10',
title: 'text-xl font-bold text-[#3D4E4B] tracking-tight',
htmlContainer: 'text-sm font-medium text-gray-500 tracking-tight leading-relaxed',
confirmButton: 'px-10 py-3.5 bg-[#3D4E4B] text-white text-sm font-bold tracking-tight rounded-full hover:bg-[#2D3A38] transition-all ml-3 shadow-lg shadow-[#3D4E4B]/20',
cancelButton: 'px-10 py-3.5 bg-white text-[#3D4E4B] border border-gray-100 text-sm font-bold tracking-tight rounded-full hover:bg-gray-50 transition-all',
actions: 'mt-8'
},
});
};
export const swalConfirmDelete = (itemName: string) => {
return Swal.fire({
title: `Delete ${itemName}?`,
text: 'This action is irreversible. All data assets will be permanently purged.',
icon: 'error',
showCancelButton: true,
confirmButtonText: 'Yes, delete',
cancelButtonText: 'Cancel',
reverseButtons: true,
buttonsStyling: false,
customClass: {
popup: '!rounded-[3rem] border-none shadow-2xl font-sans p-10',
title: 'text-xl font-bold text-[#3D4E4B] tracking-tight',
htmlContainer: 'text-sm font-medium text-gray-500 tracking-tight leading-relaxed',
confirmButton: 'px-10 py-3.5 bg-red-600 text-white text-sm font-bold tracking-tight rounded-full hover:bg-red-700 transition-all ml-3 shadow-lg shadow-red-500/20',
cancelButton: 'px-10 py-3.5 bg-white text-[#3D4E4B] border border-gray-200 text-sm font-bold tracking-tight rounded-full hover:bg-gray-50 transition-all',
actions: 'mt-8'
},
});
};
// Legacy compatibility object
export const swal = {
success: swalSuccess,
error: swalError,
confirm: swalConfirm,
confirmDelete: swalConfirmDelete
};
export default swal;
+29
View File
@@ -0,0 +1,29 @@
import { route as ziggyRoute } from 'ziggy-js';
declare global {
var route: typeof ziggyRoute;
}
export interface User {
id: number;
name: string;
first_name: string;
last_name: string;
email: string;
email_verified_at?: string;
avatar_url?: string;
status: string;
phone?: string;
bio?: string;
deleted_at?: string | null;
}
export type PageProps<
T extends Record<string, unknown> = Record<string, unknown>,
> = T & {
auth: {
user: User;
roles: string[];
permissions: string[];
};
};
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>{{ config('app.name', 'Laravel') }}</title>
@php
$settings = \Illuminate\Support\Facades\Cache::get('system_settings', []);
$favicon = $settings['app_logo'] ?? null;
@endphp
@if($favicon)
<link rel="icon" type="image/x-icon" href="{{ $favicon }}">
@endif
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Scripts -->
@routes
@viteReactRefresh
@vite(['resources/js/app.tsx', "resources/js/Pages/{$page['component']}.tsx"])
@inertiaHead
</head>
<body class="font-sans antialiased">
@inertia
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
@extends('errors.layout')
@section('title', '403 — Access Forbidden')
@section('code', '403')
@section('badge-label', 'Access Denied')
@section('dot-color', '#EF4444')
@section('icon-bg', '#FEF2F2')
@section('icon-color', '#EF4444')
@section('icon')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="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>
@endsection
@section('heading', 'Access Forbidden')
@section('description', "You don't have the required permissions to access this resource. Contact your administrator if you believe this is a mistake.")
@section('actions')
<a href="{{ url('/dashboard') }}" class="btn-primary">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Dashboard
</a>
<a href="javascript:history.back()" class="btn-secondary">Go Back</a>
@endsection
+27
View File
@@ -0,0 +1,27 @@
@extends('errors.layout')
@section('title', '404 — Page Not Found')
@section('code', '404')
@section('badge-label', 'Page Not Found')
@section('dot-color', '#3B82F6')
@section('icon-bg', '#EFF6FF')
@section('icon-color', '#3B82F6')
@section('icon')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@endsection
@section('heading', 'Page Not Found')
@section('description', "The page you're looking for has been moved, deleted, or simply doesn't exist in this system.")
@section('actions')
<a href="{{ url('/dashboard') }}" class="btn-primary">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Dashboard
</a>
<a href="javascript:history.back()" class="btn-secondary">Go Back</a>
@endsection
+27
View File
@@ -0,0 +1,27 @@
@extends('errors.layout')
@section('title', '500 — Server Error')
@section('code', '500')
@section('badge-label', 'Internal Server Error')
@section('dot-color', '#F59E0B')
@section('icon-bg', '#FEF3C7')
@section('icon-color', '#F59E0B')
@section('icon')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="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>
@endsection
@section('heading', 'Something Went Wrong')
@section('description', 'The server encountered an unexpected condition that prevented it from fulfilling the request. Our team has been notified and is working on a fix.')
@section('actions')
<a href="{{ url('/dashboard') }}" class="btn-primary">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Dashboard
</a>
<button onclick="window.location.reload()" class="btn-secondary">Try Again</button>
@endsection
+171
View File
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@yield('title') biiproject kit v2</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; font-family: 'Inter', sans-serif; background: #E3EBE8; color: #3D4E4B; }
.wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
position: relative;
overflow: hidden;
}
/* Background orbs */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.25;
pointer-events: none;
}
.orb-1 { width: 500px; height: 500px; background: #3D4E4B; top: -100px; right: -100px; }
.orb-2 { width: 400px; height: 400px; background: #D4A017; bottom: -80px; left: -80px; }
.card {
background: white;
border-radius: 2.5rem;
padding: 4rem;
max-width: 560px;
width: 100%;
text-align: center;
border: 1px solid rgba(0,0,0,0.05);
box-shadow: 0 25px 60px rgba(61,78,75,0.08);
position: relative;
z-index: 1;
animation: fadeUp 0.5s ease both;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #E3EBE8;
border-radius: 999px;
padding: 0.4rem 1rem;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #3D4E4B;
margin-bottom: 2rem;
}
.badge-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: @yield('dot-color', '#D4A017');
}
.code {
font-size: 7rem;
font-weight: 900;
color: #3D4E4B;
line-height: 1;
letter-spacing: -0.04em;
margin-bottom: 0.5rem;
opacity: 0.08;
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 3rem;
user-select: none;
white-space: nowrap;
}
.icon-wrap {
width: 80px; height: 80px;
border-radius: 1.5rem;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 1.5rem;
background: @yield('icon-bg', '#FEF3C7');
}
.icon-wrap svg { width: 36px; height: 36px; color: @yield('icon-color', '#D4A017'); }
h1 { font-size: 1.75rem; font-weight: 800; color: #3D4E4B; letter-spacing: -0.03em; margin-bottom: 0.75rem; }
p { font-size: 0.9rem; color: #6B7280; font-weight: 500; line-height: 1.7; max-width: 360px; margin: 0 auto 2.5rem; }
.btn-group { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.btn-primary {
display: inline-flex; align-items: center; gap: 0.5rem;
background: #3D4E4B; color: white;
padding: 0.75rem 1.75rem;
border-radius: 0.875rem;
font-size: 0.8rem; font-weight: 700;
text-decoration: none;
border: none; cursor: pointer;
transition: background 0.2s, transform 0.1s;
}
.btn-primary:hover { background: #2D3A38; transform: translateY(-1px); }
.btn-secondary {
display: inline-flex; align-items: center; gap: 0.5rem;
background: transparent; color: #6B7280;
padding: 0.75rem 1.75rem;
border-radius: 0.875rem;
font-size: 0.8rem; font-weight: 700;
text-decoration: none;
border: 1.5px solid #E5E7EB; cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover { border-color: #3D4E4B; color: #3D4E4B; }
.footer-brand {
margin-top: 3rem;
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
}
.brand-logo {
width: 28px; height: 28px; border-radius: 0.5rem;
background: #3D4E4B; color: #D4A017;
display: flex; align-items: center; justify-content: center;
font-size: 0.65rem; font-weight: 900;
}
.brand-name { font-size: 0.75rem; font-weight: 700; color: #9CA3AF; }
.brand-version { font-size: 0.65rem; font-weight: 800; color: #D4A017; }
</style>
</head>
<body>
<div class="wrapper">
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="card">
<span class="code">@yield('code')</span>
<div class="badge">
<span class="badge-dot"></span>
@yield('badge-label', 'System Error')
</div>
<div class="icon-wrap">
@yield('icon')
</div>
<h1>@yield('heading')</h1>
<p>@yield('description')</p>
<div class="btn-group">
@yield('actions')
</div>
<div class="footer-brand">
<div class="brand-logo">BK</div>
<span class="brand-name">biiproject kit</span>
<span class="brand-version">v2</span>
</div>
</div>
</div>
</body>
</html>
+920
View File
@@ -0,0 +1,920 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Laravel API Documentation</title>
<link href="https://fonts.googleapis.com/css?family=Open+Sans&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ asset("/vendor/scribe/css/theme-default.style.css") }}" media="screen">
<link rel="stylesheet" href="{{ asset("/vendor/scribe/css/theme-default.print.css") }}" media="print">
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
<link rel="stylesheet"
href="https://unpkg.com/@highlightjs/cdn-assets@11.6.0/styles/obsidian.min.css">
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.6.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jets/0.14.1/jets.min.js"></script>
<style id="language-style">
/* starts out as display none and is replaced with js later */
body .content .bash-example code { display: none; }
body .content .javascript-example code { display: none; }
</style>
<script>
var tryItOutBaseUrl = "http://localhost";
var useCsrf = Boolean();
var csrfUrl = "/sanctum/csrf-cookie";
</script>
<script src="{{ asset("/vendor/scribe/js/tryitout-5.9.0.js") }}"></script>
<script src="{{ asset("/vendor/scribe/js/theme-default-5.9.0.js") }}"></script>
</head>
<body data-languages="[&quot;bash&quot;,&quot;javascript&quot;]">
<a href="#" id="nav-button">
<span>
MENU
<img src="{{ asset("/vendor/scribe/images/navbar.png") }}" alt="navbar-image"/>
</span>
</a>
<div class="tocify-wrapper">
<div class="lang-selector">
<button type="button" class="lang-button" data-language-name="bash">bash</button>
<button type="button" class="lang-button" data-language-name="javascript">javascript</button>
</div>
<div class="search">
<input type="text" class="search" id="input-search" placeholder="Search">
</div>
<div id="toc">
<ul id="tocify-header-introduction" class="tocify-header">
<li class="tocify-item level-1" data-unique="introduction">
<a href="#introduction">Introduction</a>
</li>
</ul>
<ul id="tocify-header-authenticating-requests" class="tocify-header">
<li class="tocify-item level-1" data-unique="authenticating-requests">
<a href="#authenticating-requests">Authenticating requests</a>
</li>
</ul>
<ul id="tocify-header-authentication" class="tocify-header">
<li class="tocify-item level-1" data-unique="authentication">
<a href="#authentication">Authentication</a>
</li>
<ul id="tocify-subheader-authentication" class="tocify-subheader">
<li class="tocify-item level-2" data-unique="authentication-POSTapi-v1-auth-login">
<a href="#authentication-POSTapi-v1-auth-login">Login</a>
</li>
<li class="tocify-item level-2" data-unique="authentication-POSTapi-v1-auth-logout">
<a href="#authentication-POSTapi-v1-auth-logout">Logout</a>
</li>
</ul>
</ul>
<ul id="tocify-header-user-management" class="tocify-header">
<li class="tocify-item level-1" data-unique="user-management">
<a href="#user-management">User Management</a>
</li>
<ul id="tocify-subheader-user-management" class="tocify-subheader">
<li class="tocify-item level-2" data-unique="user-management-POSTapi-v1-users">
<a href="#user-management-POSTapi-v1-users">Create User</a>
</li>
<li class="tocify-item level-2" data-unique="user-management-PUTapi-v1-users--id-">
<a href="#user-management-PUTapi-v1-users--id-">Update User</a>
</li>
<li class="tocify-item level-2" data-unique="user-management-DELETEapi-v1-users--id-">
<a href="#user-management-DELETEapi-v1-users--id-">Delete User</a>
</li>
</ul>
</ul>
</div>
<ul class="toc-footer" id="toc-footer">
<li style="padding-bottom: 5px;"><a href="{{ route("scribe.postman") }}">View Postman collection</a></li>
<li style="padding-bottom: 5px;"><a href="{{ route("scribe.openapi") }}">View OpenAPI spec</a></li>
<li><a href="http://github.com/knuckleswtf/scribe">Documentation powered by Scribe </a></li>
</ul>
<ul class="toc-footer" id="last-updated">
<li>Last updated: May 7, 2026</li>
</ul>
</div>
<div class="page-wrapper">
<div class="dark-box"></div>
<div class="content">
<h1 id="introduction">Introduction</h1>
<aside>
<strong>Base URL</strong>: <code>http://localhost</code>
</aside>
<pre><code>This documentation aims to provide all the information you need to work with our API.
&lt;aside&gt;As you scroll, you'll see code examples for working with the API in different programming languages in the dark area to the right (or as part of the content on mobile).
You can switch the language used with the tabs at the top right (or from the nav menu at the top left on mobile).&lt;/aside&gt;</code></pre>
<h1 id="authenticating-requests">Authenticating requests</h1>
<p>This API is not authenticated.</p>
<h1 id="authentication">Authentication</h1>
<p>APIs for managing authentication</p>
<h2 id="authentication-POSTapi-v1-auth-login">Login</h2>
<p>
</p>
<p>Authenticate a user and return a Sanctum token.</p>
<span id="example-requests-POSTapi-v1-auth-login">
<blockquote>Example request:</blockquote>
<div class="bash-example">
<pre><code class="language-bash">curl --request POST \
"http://localhost/api/v1/auth/login" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email\": \"gbailey@example.net\",
\"password\": \"architecto\"
}"
</code></pre></div>
<div class="javascript-example">
<pre><code class="language-javascript">const url = new URL(
"http://localhost/api/v1/auth/login"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email": "gbailey@example.net",
"password": "architecto"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response =&gt; response.json());</code></pre></div>
</span>
<span id="example-responses-POSTapi-v1-auth-login">
</span>
<span id="execution-results-POSTapi-v1-auth-login" hidden>
<blockquote>Received response<span
id="execution-response-status-POSTapi-v1-auth-login"></span>:
</blockquote>
<pre class="json"><code id="execution-response-content-POSTapi-v1-auth-login"
data-empty-response-text="<Empty response>" style="max-height: 400px;"></code></pre>
</span>
<span id="execution-error-POSTapi-v1-auth-login" hidden>
<blockquote>Request failed with error:</blockquote>
<pre><code id="execution-error-message-POSTapi-v1-auth-login">
Tip: Check that you&#039;re properly connected to the network.
If you&#039;re a maintainer of ths API, verify that your API is running and you&#039;ve enabled CORS.
You can check the Dev Tools console for debugging information.</code></pre>
</span>
<form id="form-POSTapi-v1-auth-login" data-method="POST"
data-path="api/v1/auth/login"
data-authed="0"
data-hasfiles="0"
data-isarraybody="0"
autocomplete="off"
onsubmit="event.preventDefault(); executeTryOut('POSTapi-v1-auth-login', this);">
<h3>
Request&nbsp;&nbsp;&nbsp;
<button type="button"
style="background-color: #8fbcd4; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-tryout-POSTapi-v1-auth-login"
onclick="tryItOut('POSTapi-v1-auth-login');">Try it out ⚡
</button>
<button type="button"
style="background-color: #c97a7e; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-canceltryout-POSTapi-v1-auth-login"
onclick="cancelTryOut('POSTapi-v1-auth-login');" hidden>Cancel 🛑
</button>&nbsp;&nbsp;
<button type="submit"
style="background-color: #6ac174; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-executetryout-POSTapi-v1-auth-login"
data-initial-text="Send Request 💥"
data-loading-text="⏱ Sending..."
hidden>Send Request 💥
</button>
</h3>
<p>
<small class="badge badge-black">POST</small>
<b><code>api/v1/auth/login</code></b>
</p>
<h4 class="fancy-heading-panel"><b>Headers</b></h4>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Content-Type</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Content-Type" data-endpoint="POSTapi-v1-auth-login"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Accept</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Accept" data-endpoint="POSTapi-v1-auth-login"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
<h4 class="fancy-heading-panel"><b>Body Parameters</b></h4>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>email</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="email" data-endpoint="POSTapi-v1-auth-login"
value="gbailey@example.net"
data-component="body">
<br>
<p>Must be a valid email address. Example: <code>gbailey@example.net</code></p>
</div>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>password</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="password" data-endpoint="POSTapi-v1-auth-login"
value="architecto"
data-component="body">
<br>
<p>Example: <code>architecto</code></p>
</div>
</form>
<h2 id="authentication-POSTapi-v1-auth-logout">Logout</h2>
<p>
</p>
<p>Revoke the current user's token.</p>
<span id="example-requests-POSTapi-v1-auth-logout">
<blockquote>Example request:</blockquote>
<div class="bash-example">
<pre><code class="language-bash">curl --request POST \
"http://localhost/api/v1/auth/logout" \
--header "Content-Type: application/json" \
--header "Accept: application/json"</code></pre></div>
<div class="javascript-example">
<pre><code class="language-javascript">const url = new URL(
"http://localhost/api/v1/auth/logout"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response =&gt; response.json());</code></pre></div>
</span>
<span id="example-responses-POSTapi-v1-auth-logout">
</span>
<span id="execution-results-POSTapi-v1-auth-logout" hidden>
<blockquote>Received response<span
id="execution-response-status-POSTapi-v1-auth-logout"></span>:
</blockquote>
<pre class="json"><code id="execution-response-content-POSTapi-v1-auth-logout"
data-empty-response-text="<Empty response>" style="max-height: 400px;"></code></pre>
</span>
<span id="execution-error-POSTapi-v1-auth-logout" hidden>
<blockquote>Request failed with error:</blockquote>
<pre><code id="execution-error-message-POSTapi-v1-auth-logout">
Tip: Check that you&#039;re properly connected to the network.
If you&#039;re a maintainer of ths API, verify that your API is running and you&#039;ve enabled CORS.
You can check the Dev Tools console for debugging information.</code></pre>
</span>
<form id="form-POSTapi-v1-auth-logout" data-method="POST"
data-path="api/v1/auth/logout"
data-authed="0"
data-hasfiles="0"
data-isarraybody="0"
autocomplete="off"
onsubmit="event.preventDefault(); executeTryOut('POSTapi-v1-auth-logout', this);">
<h3>
Request&nbsp;&nbsp;&nbsp;
<button type="button"
style="background-color: #8fbcd4; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-tryout-POSTapi-v1-auth-logout"
onclick="tryItOut('POSTapi-v1-auth-logout');">Try it out
</button>
<button type="button"
style="background-color: #c97a7e; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-canceltryout-POSTapi-v1-auth-logout"
onclick="cancelTryOut('POSTapi-v1-auth-logout');" hidden>Cancel 🛑
</button>&nbsp;&nbsp;
<button type="submit"
style="background-color: #6ac174; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-executetryout-POSTapi-v1-auth-logout"
data-initial-text="Send Request 💥"
data-loading-text="⏱ Sending..."
hidden>Send Request 💥
</button>
</h3>
<p>
<small class="badge badge-black">POST</small>
<b><code>api/v1/auth/logout</code></b>
</p>
<h4 class="fancy-heading-panel"><b>Headers</b></h4>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Content-Type</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Content-Type" data-endpoint="POSTapi-v1-auth-logout"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Accept</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Accept" data-endpoint="POSTapi-v1-auth-logout"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
</form>
<h1 id="user-management">User Management</h1>
<p>APIs for managing users</p>
<h2 id="user-management-POSTapi-v1-users">Create User</h2>
<p>
</p>
<p>Create a new user with roles.</p>
<span id="example-requests-POSTapi-v1-users">
<blockquote>Example request:</blockquote>
<div class="bash-example">
<pre><code class="language-bash">curl --request POST \
"http://localhost/api/v1/users" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"firstName\": \"b\",
\"lastName\": \"n\",
\"email\": \"ashly64@example.com\",
\"password\": \"pBNvYg\",
\"status\": \"inactive\"
}"
</code></pre></div>
<div class="javascript-example">
<pre><code class="language-javascript">const url = new URL(
"http://localhost/api/v1/users"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"firstName": "b",
"lastName": "n",
"email": "ashly64@example.com",
"password": "pBNvYg",
"status": "inactive"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response =&gt; response.json());</code></pre></div>
</span>
<span id="example-responses-POSTapi-v1-users">
</span>
<span id="execution-results-POSTapi-v1-users" hidden>
<blockquote>Received response<span
id="execution-response-status-POSTapi-v1-users"></span>:
</blockquote>
<pre class="json"><code id="execution-response-content-POSTapi-v1-users"
data-empty-response-text="<Empty response>" style="max-height: 400px;"></code></pre>
</span>
<span id="execution-error-POSTapi-v1-users" hidden>
<blockquote>Request failed with error:</blockquote>
<pre><code id="execution-error-message-POSTapi-v1-users">
Tip: Check that you&#039;re properly connected to the network.
If you&#039;re a maintainer of ths API, verify that your API is running and you&#039;ve enabled CORS.
You can check the Dev Tools console for debugging information.</code></pre>
</span>
<form id="form-POSTapi-v1-users" data-method="POST"
data-path="api/v1/users"
data-authed="0"
data-hasfiles="0"
data-isarraybody="0"
autocomplete="off"
onsubmit="event.preventDefault(); executeTryOut('POSTapi-v1-users', this);">
<h3>
Request&nbsp;&nbsp;&nbsp;
<button type="button"
style="background-color: #8fbcd4; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-tryout-POSTapi-v1-users"
onclick="tryItOut('POSTapi-v1-users');">Try it out
</button>
<button type="button"
style="background-color: #c97a7e; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-canceltryout-POSTapi-v1-users"
onclick="cancelTryOut('POSTapi-v1-users');" hidden>Cancel 🛑
</button>&nbsp;&nbsp;
<button type="submit"
style="background-color: #6ac174; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-executetryout-POSTapi-v1-users"
data-initial-text="Send Request 💥"
data-loading-text="⏱ Sending..."
hidden>Send Request 💥
</button>
</h3>
<p>
<small class="badge badge-black">POST</small>
<b><code>api/v1/users</code></b>
</p>
<h4 class="fancy-heading-panel"><b>Headers</b></h4>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Content-Type</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Content-Type" data-endpoint="POSTapi-v1-users"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Accept</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Accept" data-endpoint="POSTapi-v1-users"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
<h4 class="fancy-heading-panel"><b>Body Parameters</b></h4>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>firstName</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="firstName" data-endpoint="POSTapi-v1-users"
value="b"
data-component="body">
<br>
<p>Must not be greater than 100 characters. Example: <code>b</code></p>
</div>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>lastName</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="lastName" data-endpoint="POSTapi-v1-users"
value="n"
data-component="body">
<br>
<p>Must not be greater than 100 characters. Example: <code>n</code></p>
</div>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>email</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="email" data-endpoint="POSTapi-v1-users"
value="ashly64@example.com"
data-component="body">
<br>
<p>Must be a valid email address. Example: <code>ashly64@example.com</code></p>
</div>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>password</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="password" data-endpoint="POSTapi-v1-users"
value="pBNvYg"
data-component="body">
<br>
<p>Must be at least 8 characters. Example: <code>pBNvYg</code></p>
</div>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>status</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
<i>optional</i> &nbsp;
&nbsp;
<input type="text" style="display: none"
name="status" data-endpoint="POSTapi-v1-users"
value="inactive"
data-component="body">
<br>
<p>Example: <code>inactive</code></p>
Must be one of:
<ul style="list-style-type: square;"><li><code>active</code></li> <li><code>inactive</code></li> <li><code>suspended</code></li></ul>
</div>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>roles</code></b>&nbsp;&nbsp;
<small>object</small>&nbsp;
<i>optional</i> &nbsp;
&nbsp;
<input type="text" style="display: none"
name="roles" data-endpoint="POSTapi-v1-users"
value=""
data-component="body">
<br>
</div>
</form>
<h2 id="user-management-PUTapi-v1-users--id-">Update User</h2>
<p>
</p>
<p>Update a user's details.</p>
<span id="example-requests-PUTapi-v1-users--id-">
<blockquote>Example request:</blockquote>
<div class="bash-example">
<pre><code class="language-bash">curl --request PUT \
"http://localhost/api/v1/users/16" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"firstName\": \"b\",
\"lastName\": \"n\",
\"status\": \"inactive\"
}"
</code></pre></div>
<div class="javascript-example">
<pre><code class="language-javascript">const url = new URL(
"http://localhost/api/v1/users/16"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"firstName": "b",
"lastName": "n",
"status": "inactive"
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response =&gt; response.json());</code></pre></div>
</span>
<span id="example-responses-PUTapi-v1-users--id-">
</span>
<span id="execution-results-PUTapi-v1-users--id-" hidden>
<blockquote>Received response<span
id="execution-response-status-PUTapi-v1-users--id-"></span>:
</blockquote>
<pre class="json"><code id="execution-response-content-PUTapi-v1-users--id-"
data-empty-response-text="<Empty response>" style="max-height: 400px;"></code></pre>
</span>
<span id="execution-error-PUTapi-v1-users--id-" hidden>
<blockquote>Request failed with error:</blockquote>
<pre><code id="execution-error-message-PUTapi-v1-users--id-">
Tip: Check that you&#039;re properly connected to the network.
If you&#039;re a maintainer of ths API, verify that your API is running and you&#039;ve enabled CORS.
You can check the Dev Tools console for debugging information.</code></pre>
</span>
<form id="form-PUTapi-v1-users--id-" data-method="PUT"
data-path="api/v1/users/{id}"
data-authed="0"
data-hasfiles="0"
data-isarraybody="0"
autocomplete="off"
onsubmit="event.preventDefault(); executeTryOut('PUTapi-v1-users--id-', this);">
<h3>
Request&nbsp;&nbsp;&nbsp;
<button type="button"
style="background-color: #8fbcd4; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-tryout-PUTapi-v1-users--id-"
onclick="tryItOut('PUTapi-v1-users--id-');">Try it out ⚡
</button>
<button type="button"
style="background-color: #c97a7e; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-canceltryout-PUTapi-v1-users--id-"
onclick="cancelTryOut('PUTapi-v1-users--id-');" hidden>Cancel 🛑
</button>&nbsp;&nbsp;
<button type="submit"
style="background-color: #6ac174; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-executetryout-PUTapi-v1-users--id-"
data-initial-text="Send Request 💥"
data-loading-text="⏱ Sending..."
hidden>Send Request 💥
</button>
</h3>
<p>
<small class="badge badge-darkblue">PUT</small>
<b><code>api/v1/users/{id}</code></b>
</p>
<p>
<small class="badge badge-purple">PATCH</small>
<b><code>api/v1/users/{id}</code></b>
</p>
<h4 class="fancy-heading-panel"><b>Headers</b></h4>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Content-Type</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Content-Type" data-endpoint="PUTapi-v1-users--id-"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Accept</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Accept" data-endpoint="PUTapi-v1-users--id-"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
<h4 class="fancy-heading-panel"><b>URL Parameters</b></h4>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>id</code></b>&nbsp;&nbsp;
<small>integer</small>&nbsp;
&nbsp;
&nbsp;
<input type="number" style="display: none"
step="any" name="id" data-endpoint="PUTapi-v1-users--id-"
value="16"
data-component="url">
<br>
<p>The ID of the user. Example: <code>16</code></p>
</div>
<h4 class="fancy-heading-panel"><b>Body Parameters</b></h4>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>firstName</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
<i>optional</i> &nbsp;
&nbsp;
<input type="text" style="display: none"
name="firstName" data-endpoint="PUTapi-v1-users--id-"
value="b"
data-component="body">
<br>
<p>Must not be greater than 100 characters. Example: <code>b</code></p>
</div>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>lastName</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
<i>optional</i> &nbsp;
&nbsp;
<input type="text" style="display: none"
name="lastName" data-endpoint="PUTapi-v1-users--id-"
value="n"
data-component="body">
<br>
<p>Must not be greater than 100 characters. Example: <code>n</code></p>
</div>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>email</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
<i>optional</i> &nbsp;
&nbsp;
<input type="text" style="display: none"
name="email" data-endpoint="PUTapi-v1-users--id-"
value=""
data-component="body">
<br>
</div>
<div style=" padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>status</code></b>&nbsp;&nbsp;
<small>string</small>&nbsp;
<i>optional</i> &nbsp;
&nbsp;
<input type="text" style="display: none"
name="status" data-endpoint="PUTapi-v1-users--id-"
value="inactive"
data-component="body">
<br>
<p>Example: <code>inactive</code></p>
Must be one of:
<ul style="list-style-type: square;"><li><code>active</code></li> <li><code>inactive</code></li> <li><code>suspended</code></li></ul>
</div>
</form>
<h2 id="user-management-DELETEapi-v1-users--id-">Delete User</h2>
<p>
</p>
<p>Soft delete a user.</p>
<span id="example-requests-DELETEapi-v1-users--id-">
<blockquote>Example request:</blockquote>
<div class="bash-example">
<pre><code class="language-bash">curl --request DELETE \
"http://localhost/api/v1/users/16" \
--header "Content-Type: application/json" \
--header "Accept: application/json"</code></pre></div>
<div class="javascript-example">
<pre><code class="language-javascript">const url = new URL(
"http://localhost/api/v1/users/16"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response =&gt; response.json());</code></pre></div>
</span>
<span id="example-responses-DELETEapi-v1-users--id-">
</span>
<span id="execution-results-DELETEapi-v1-users--id-" hidden>
<blockquote>Received response<span
id="execution-response-status-DELETEapi-v1-users--id-"></span>:
</blockquote>
<pre class="json"><code id="execution-response-content-DELETEapi-v1-users--id-"
data-empty-response-text="<Empty response>" style="max-height: 400px;"></code></pre>
</span>
<span id="execution-error-DELETEapi-v1-users--id-" hidden>
<blockquote>Request failed with error:</blockquote>
<pre><code id="execution-error-message-DELETEapi-v1-users--id-">
Tip: Check that you&#039;re properly connected to the network.
If you&#039;re a maintainer of ths API, verify that your API is running and you&#039;ve enabled CORS.
You can check the Dev Tools console for debugging information.</code></pre>
</span>
<form id="form-DELETEapi-v1-users--id-" data-method="DELETE"
data-path="api/v1/users/{id}"
data-authed="0"
data-hasfiles="0"
data-isarraybody="0"
autocomplete="off"
onsubmit="event.preventDefault(); executeTryOut('DELETEapi-v1-users--id-', this);">
<h3>
Request&nbsp;&nbsp;&nbsp;
<button type="button"
style="background-color: #8fbcd4; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-tryout-DELETEapi-v1-users--id-"
onclick="tryItOut('DELETEapi-v1-users--id-');">Try it out ⚡
</button>
<button type="button"
style="background-color: #c97a7e; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-canceltryout-DELETEapi-v1-users--id-"
onclick="cancelTryOut('DELETEapi-v1-users--id-');" hidden>Cancel 🛑
</button>&nbsp;&nbsp;
<button type="submit"
style="background-color: #6ac174; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-executetryout-DELETEapi-v1-users--id-"
data-initial-text="Send Request 💥"
data-loading-text="⏱ Sending..."
hidden>Send Request 💥
</button>
</h3>
<p>
<small class="badge badge-red">DELETE</small>
<b><code>api/v1/users/{id}</code></b>
</p>
<h4 class="fancy-heading-panel"><b>Headers</b></h4>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Content-Type</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Content-Type" data-endpoint="DELETEapi-v1-users--id-"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>Accept</code></b>&nbsp;&nbsp;
&nbsp;
&nbsp;
&nbsp;
<input type="text" style="display: none"
name="Accept" data-endpoint="DELETEapi-v1-users--id-"
value="application/json"
data-component="header">
<br>
<p>Example: <code>application/json</code></p>
</div>
<h4 class="fancy-heading-panel"><b>URL Parameters</b></h4>
<div style="padding-left: 28px; clear: unset;">
<b style="line-height: 2;"><code>id</code></b>&nbsp;&nbsp;
<small>integer</small>&nbsp;
&nbsp;
&nbsp;
<input type="number" style="display: none"
step="any" name="id" data-endpoint="DELETEapi-v1-users--id-"
value="16"
data-component="url">
<br>
<p>The ID of the user. Example: <code>16</code></p>
</div>
</form>
</div>
<div class="dark-box">
<div class="lang-selector">
<button type="button" class="lang-button" data-language-name="bash">bash</button>
<button type="button" class="lang-button" data-language-name="javascript">javascript</button>
</div>
</div>
</div>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
@props([
'url',
'color' => 'primary',
'align' => 'center',
])
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<a href="{{ $url }}" class="button button-{{ $color }}" target="_blank" rel="noopener">{!! $slot !!}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
+18
View File
@@ -0,0 +1,18 @@
@php
$settings = \Illuminate\Support\Facades\Cache::get('system_settings', []);
$appName = $settings['app_name'] ?? config('app.name');
@endphp
<tr>
<td>
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p>
© {{ date('Y') }} {{ $appName }}. All rights reserved. <br>
Precision Identity Architecture & Governance.
</p>
</td>
</tr>
</table>
</td>
</tr>
+20
View File
@@ -0,0 +1,20 @@
@props(['url'])
@php
$settings = \Illuminate\Support\Facades\Cache::get('system_settings', []);
$appName = $settings['app_name'] ?? config('app.name');
$appLogo = $settings['app_logo'] ?? null;
$appLogoText = $settings['app_logo_text'] ?? substr($appName, 0, 1);
@endphp
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block; text-align: center;">
@if ($appLogo)
<img src="{{ $appLogo }}" class="logo" alt="{{ $appName }} Logo">
<br>
@endif
<span style="font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 900; font-size: 18px; color: #3D4E4B; letter-spacing: 0.15em; text-transform: uppercase;">
{{ $appName }}
</span>
</a>
</td>
</tr>
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<title>{{ config('app.name') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<style>
@media only screen and (max-width: 600px) {
.inner-body {
width: 100% !important;
}
.footer {
width: 100% !important;
}
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
}
}
</style>
{!! $head ?? '' !!}
</head>
<body>
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
{!! $header ?? '' !!}
<!-- Email Body -->
<tr>
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
{!! Illuminate\Mail\Markdown::parse($slot) !!}
{!! $subcopy ?? '' !!}
</td>
</tr>
</table>
</td>
</tr>
{!! $footer ?? '' !!}
</table>
</td>
</tr>
</table>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{!! $slot !!}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{!! $subcopy !!}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>
+14
View File
@@ -0,0 +1,14 @@
<table class="panel" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-content">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-item">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>
</table>
+7
View File
@@ -0,0 +1,7 @@
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
+3
View File
@@ -0,0 +1,3 @@
<div class="table">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</div>
+161
View File
@@ -0,0 +1,161 @@
/* resources/views/vendor/mail/html/themes/default.css */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
box-sizing: border-box;
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
position: relative;
}
body {
-webkit-text-size-adjust: none;
background-color: #E3EBE8;
color: #3D4E4B;
height: 100%;
line-height: 1.6;
margin: 0;
padding: 0;
width: 100% !important;
}
p, ul, ol, blockquote {
line-height: 1.6;
text-align: start;
}
a {
color: #D4A017;
text-decoration: none;
font-weight: bold;
}
h1 {
color: #3D4E4B;
font-size: 22px;
font-weight: 800;
margin-top: 0;
text-align: start;
text-transform: uppercase;
letter-spacing: -0.02em;
}
h2 {
font-size: 18px;
font-weight: 800;
margin-top: 0;
text-align: start;
color: #3D4E4B;
}
p {
font-size: 15px;
line-height: 1.6em;
margin-top: 0;
text-align: left;
color: #4A5568;
}
.wrapper {
background-color: #E3EBE8;
margin: 0;
padding: 0;
width: 100%;
}
.header {
padding: 40px 0;
text-align: center;
}
.header a {
color: #3D4E4B;
font-size: 20px;
font-weight: 900;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.2em;
}
.logo {
height: 64px;
margin-bottom: 15px;
max-height: 64px;
border-radius: 16px;
}
.body {
background-color: #E3EBE8;
border-bottom: 0;
border-top: 0;
margin: 0;
padding: 0;
width: 100%;
}
.inner-body {
background-color: #ffffff;
border-color: #E3EBE8;
border-radius: 32px;
border-width: 1px;
box-shadow: 0 10px 40px rgba(61, 78, 75, 0.05);
margin: 0 auto;
padding: 0;
width: 570px;
}
.content-cell {
padding: 48px;
}
.footer {
margin: 0 auto;
padding: 32px 0;
text-align: center;
width: 570px;
}
.footer p {
color: #3D4E4B;
opacity: 0.4;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
text-align: center;
}
.button {
border-radius: 16px;
color: #fff;
display: inline-block;
overflow: hidden;
text-decoration: none;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 12px;
}
.button-primary {
background-color: #3D4E4B;
border-bottom: 12px solid #3D4E4B;
border-left: 32px solid #3D4E4B;
border-right: 32px solid #3D4E4B;
border-top: 12px solid #3D4E4B;
}
.panel {
border-left: #D4A017 solid 4px;
margin: 24px 0;
background-color: #F7FAFA;
border-radius: 0 12px 12px 0;
}
.panel-content {
padding: 16px;
}
.subcopy {
border-top: 1px solid #E3EBE8;
margin-top: 32px;
padding-top: 24px;
}
+1
View File
@@ -0,0 +1 @@
{{ $slot }}: {{ $url }}
+1
View File
@@ -0,0 +1 @@
{{ $slot }}
+1
View File
@@ -0,0 +1 @@
{{ $slot }}: {{ $url }}
+9
View File
@@ -0,0 +1,9 @@
{!! strip_tags($header ?? '') !!}
{!! strip_tags($slot) !!}
@isset($subcopy)
{!! strip_tags($subcopy) !!}
@endisset
{!! strip_tags($footer ?? '') !!}
+27
View File
@@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{{ $slot }}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>
+1
View File
@@ -0,0 +1 @@
{{ $slot }}
+1
View File
@@ -0,0 +1 @@
{{ $slot }}
+1
View File
@@ -0,0 +1 @@
{{ $slot }}