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

369 lines
26 KiB
TypeScript

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>
);
}