feat: Initialize License Tracker Pro v1.2 project

Adds initial project structure, dependencies, and basic configuration for the License Tracker Pro v1.2 application. This includes setting up Vite for the build process, defining core types, and incorporating essential UI components like icons and a timeline. The project is now ready for development.
This commit is contained in:
Philip
2026-01-26 18:55:43 -08:00
parent d4895c6d81
commit e045354892
56 changed files with 3410 additions and 8 deletions
+302
View File
@@ -0,0 +1,302 @@
import React, { useEffect, useState, useRef } from 'react';
import { AppSettings, User, NotificationGroup, Contact } from '../types';
import {
fetchSettings, saveSettings, sendTestEmail,
fetchUsers, createUser, deleteUser, updateUser,
fetchGroups, saveGroup, deleteGroup,
fetchContacts, saveContact, deleteContact,
DEFAULT_SETTINGS
} from '../graphService';
import { TrashIcon, PlusIcon, SaveIcon, UserIcon, BriefcaseIcon, BellIcon, SettingsIcon, EditIcon } from './Icons';
interface AdminSettingsProps {
onSettingsUpdate?: (settings: AppSettings) => void;
}
const AdminSettings: React.FC<AdminSettingsProps> = ({ onSettingsUpdate }) => {
const [activeTab, setActiveTab] = useState<'general' | 'auth' | 'users' | 'groups' | 'vendors'>('general');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{type: 'success'|'error', text: string} | null>(null);
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const [users, setUsers] = useState<User[]>([]);
const [groups, setGroups] = useState<NotificationGroup[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]);
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [newUser, setNewUser] = useState({
username: '', password: '', email: '', role: 'editor' as 'admin'|'editor',
auth_source: 'local' as 'local'|'azure'|'ldap', sendEmail: true
});
const [newGroup, setNewGroup] = useState({ groupName: '', emails: '' });
const [newVendor, setNewVendor] = useState({ vendorName: '', contactName: '', contactEmail: '', contactPhone: '' });
const loadAllData = async () => {
setLoading(true);
try {
const [sData, uData, gData, cData] = await Promise.all([
fetchSettings().catch(() => DEFAULT_SETTINGS),
fetchUsers().catch(() => []),
fetchGroups().catch(() => []),
fetchContacts().catch(() => [])
]);
setSettings(sData || DEFAULT_SETTINGS);
setUsers(uData || []);
setGroups(gData || []);
setContacts(cData || []);
} catch (e: any) {
setMessage({ type: 'error', text: e.message });
} finally {
setLoading(false);
}
};
useEffect(() => { loadAllData(); }, []);
const handleSaveSettings = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await saveSettings(settings);
setMessage({ type: 'success', text: 'Configuration saved successfully.' });
if (onSettingsUpdate) onSettingsUpdate(settings);
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
const handleTestEmail = async () => {
const email = prompt("Enter test recipient email:", settings.smtp_from);
if (!email) return;
setLoading(true);
try {
await sendTestEmail(email);
setMessage({ type: 'success', text: `Test email sent to ${email}.` });
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
const handleCreateGroup = async (e: React.FormEvent) => {
e.preventDefault();
try {
setLoading(true);
await saveGroup(newGroup);
const updated = await fetchGroups();
setGroups(updated || []);
setNewGroup({ groupName: '', emails: '' });
setMessage({ type: 'success', text: 'Group created.' });
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
const handleCreateVendor = async (e: React.FormEvent) => {
e.preventDefault();
try {
setLoading(true);
await saveContact(newVendor);
const updated = await fetchContacts();
setContacts(updated || []);
setNewVendor({ vendorName: '', contactName: '', contactEmail: '', contactPhone: '' });
setMessage({ type: 'success', text: 'Vendor added.' });
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
const handleCreateOrUpdateUser = async (e: React.FormEvent) => {
e.preventDefault();
try {
setLoading(true);
if (editingUserId) {
const payload = { ...newUser };
if (!payload.password) delete (payload as any).password;
await updateUser(editingUserId, payload);
} else {
await createUser(newUser);
}
await loadAllData();
setEditingUserId(null);
setNewUser({ username: '', password: '', email: '', role: 'editor', auth_source: 'local', sendEmail: true });
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
return (
<div className="bg-slate-800 rounded-lg shadow-xl min-h-[600px] flex flex-col md:flex-row border border-slate-700 overflow-hidden">
<div className="w-full md:w-64 bg-slate-900/50 border-r border-slate-700 p-4 flex flex-col gap-2">
<h2 className="text-xl font-bold text-cyan-400 mb-4 px-2 tracking-tight">System Admin</h2>
{[
{id:'general', label:'Application', icon:<SettingsIcon className="w-4 h-4 mr-3"/>},
{id:'auth', label:'Identity & SSO', icon:<UserIcon className="w-4 h-4 mr-3"/>},
{id:'users', label:'Local Users', icon:<UserIcon className="w-4 h-4 mr-3"/>},
{id:'groups', label:'Alert Groups', icon:<BellIcon className="w-4 h-4 mr-3"/>},
{id:'vendors', label:'Vendors', icon:<BriefcaseIcon className="w-4 h-4 mr-3"/>}
].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id as any)} className={`text-left px-4 py-3 rounded transition-all font-medium flex items-center ${activeTab === tab.id ? 'bg-cyan-600 text-white shadow-lg shadow-cyan-900/20' : 'text-gray-400 hover:bg-slate-700 hover:text-white'}`}>
{tab.icon} {tab.label}
</button>
))}
</div>
<div className="flex-1 p-6 overflow-y-auto">
{message && (
<div className={`mb-6 p-4 rounded-xl border flex justify-between items-center animate-in fade-in slide-in-from-top ${message.type === 'success' ? 'bg-green-900/30 border-green-600/50 text-green-400' : 'bg-red-900/30 border-red-600/50 text-red-400'}`}>
<span className="text-sm font-medium">{message.text}</span>
<button onClick={() => setMessage(null)} className="text-lg opacity-50 hover:opacity-100">&times;</button>
</div>
)}
{activeTab === 'general' && (
<form onSubmit={handleSaveSettings} className="space-y-8 animate-in fade-in duration-300">
<div>
<h3 className="text-lg font-bold text-gray-200 border-b border-slate-700 pb-2 mb-4">Core Defaults</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div><label className="label-style">Fiscal Start Month</label><select value={settings.fiscal_start_month} onChange={e => setSettings({...settings, fiscal_start_month: parseInt(e.target.value)})} className="input-style">{Array.from({length:12},(_,i)=>(<option key={i+1} value={i+1}>{new Date(0,i).toLocaleString('default',{month:'long'})}</option>))}</select></div>
<div><label className="label-style">Alert Threshold (Days)</label><input type="number" value={settings.alert_days} onChange={e => setSettings({...settings, alert_days: parseInt(e.target.value)})} className="input-style" /></div>
</div>
</div>
<div>
<div className="flex justify-between items-center border-b border-slate-700 pb-2 mb-4"><h3 className="text-lg font-bold text-gray-200">SMTP Server</h3><button type="button" onClick={handleTestEmail} className="text-[10px] font-black bg-cyan-500/10 text-cyan-400 px-3 py-1.5 rounded-lg border border-cyan-500/20 hover:bg-cyan-500/20 transition-all uppercase tracking-widest">Send Test</button></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input placeholder="SMTP Host" value={settings.smtp_host} onChange={e => setSettings({...settings, smtp_host: e.target.value})} className="input-style" />
<input placeholder="Port" value={settings.smtp_port} onChange={e => setSettings({...settings, smtp_port: e.target.value})} className="input-style" />
<input placeholder="Username" value={settings.smtp_user} onChange={e => setSettings({...settings, smtp_user: e.target.value})} className="input-style" />
<input type="password" placeholder="Password" value={settings.smtp_pass} onChange={e => setSettings({...settings, smtp_pass: e.target.value})} className="input-style" />
<input placeholder="From Address" value={settings.smtp_from} onChange={e => setSettings({...settings, smtp_from: e.target.value})} className="input-style md:col-span-2" />
</div>
</div>
<div className="flex justify-end"><button type="submit" disabled={loading} className="primary-btn">Save Settings</button></div>
</form>
)}
{activeTab === 'auth' && (
<form onSubmit={handleSaveSettings} className="space-y-8 animate-in fade-in duration-300">
<div>
<h3 className="text-lg font-bold text-gray-200 border-b border-slate-700 pb-2 mb-4">Identity & SSO</h3>
<div className="space-y-6">
<div className="bg-slate-900/40 p-5 rounded-2xl border border-slate-700 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="font-bold text-white uppercase text-xs tracking-widest">Microsoft Entra (Azure)</p>
</div>
<input type="checkbox" checked={settings.azure_enabled} onChange={e => setSettings({...settings, azure_enabled: e.target.checked})} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-cyan-500" />
</div>
{settings.azure_enabled && (
<div className="grid grid-cols-1 gap-4 pt-2">
<input placeholder="Tenant ID" value={settings.azure_tenant_id} onChange={e => setSettings({...settings, azure_tenant_id: e.target.value})} className="input-style" />
<input placeholder="Client ID" value={settings.azure_client_id} onChange={e => setSettings({...settings, azure_client_id: e.target.value})} className="input-style" />
<input type="password" placeholder="Client Secret" value={settings.azure_client_secret} onChange={e => setSettings({...settings, azure_client_secret: e.target.value})} className="input-style" />
</div>
)}
</div>
<div className="bg-slate-900/40 p-5 rounded-2xl border border-slate-700 space-y-4">
<div className="flex items-center justify-between">
<p className="font-bold text-white uppercase text-xs tracking-widest">LDAP / Active Directory</p>
<input type="checkbox" checked={settings.ldap_enabled} onChange={e => setSettings({...settings, ldap_enabled: e.target.checked})} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-cyan-500" />
</div>
{settings.ldap_enabled && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<input placeholder="LDAP Host" value={settings.ldap_host} onChange={e => setSettings({...settings, ldap_host: e.target.value})} className="input-style" />
<input placeholder="Port" value={settings.ldap_port} onChange={e => setSettings({...settings, ldap_port: e.target.value})} className="input-style" />
<input placeholder="Base DN" value={settings.ldap_base_dn} onChange={e => setSettings({...settings, ldap_base_dn: e.target.value})} className="input-style md:col-span-2" />
<input placeholder="Bind DN" value={settings.ldap_bind_dn} onChange={e => setSettings({...settings, ldap_bind_dn: e.target.value})} className="input-style" />
<input type="password" placeholder="Bind Password" value={settings.ldap_bind_pass} onChange={e => setSettings({...settings, ldap_bind_pass: e.target.value})} className="input-style" />
<input placeholder="User Filter" value={settings.ldap_user_filter} onChange={e => setSettings({...settings, ldap_user_filter: e.target.value})} className="input-style md:col-span-2" />
</div>
)}
</div>
<div className="flex items-center justify-between bg-slate-900/40 p-4 rounded-xl border border-slate-700">
<div><p className="font-bold text-white text-xs">Allow Local DB Login</p></div>
<input type="checkbox" checked={settings.allow_local_login} onChange={e => setSettings({...settings, allow_local_login: e.target.checked})} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-cyan-500" />
</div>
</div>
</div>
<div className="flex justify-end"><button type="submit" className="primary-btn">Save Identity Settings</button></div>
</form>
)}
{activeTab === 'users' && (
<div className="space-y-6 animate-in fade-in duration-300">
<form onSubmit={handleCreateOrUpdateUser} className="bg-slate-900/50 p-6 rounded-2xl border border-slate-700 grid grid-cols-1 md:grid-cols-2 gap-4">
<input placeholder="Username" required value={newUser.username} onChange={e => setNewUser({...newUser, username: e.target.value})} className="input-style" />
<input placeholder="Email" required type="email" value={newUser.email} onChange={e => setNewUser({...newUser, email: e.target.value})} className="input-style" />
<input placeholder={editingUserId ? "New Password (Optional)" : "Password"} required={!editingUserId} type="password" value={newUser.password} onChange={e => setNewUser({...newUser, password: e.target.value})} className="input-style" />
<div className="flex gap-2">
<select value={newUser.role} onChange={e => setNewUser({...newUser, role: e.target.value as any})} className="input-style flex-1"><option value="editor">Editor</option><option value="admin">Admin</option></select>
<button type="submit" className="primary-btn px-6">Save User</button>
</div>
</form>
<div className="rounded-xl border border-slate-700 overflow-hidden bg-slate-900/20">
<table className="w-full text-left text-sm">
<thead className="bg-slate-900 text-slate-500 uppercase text-[10px] font-black"><tr><th className="p-4">User</th><th className="p-4">Role</th><th className="p-4 text-right">Actions</th></tr></thead>
<tbody>{users.map(u => (
<tr key={u.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="p-4 text-white font-bold">{u.username}<br/><span className="text-[10px] font-normal text-slate-500">{u.email}</span></td>
<td className="p-4 uppercase text-[10px] font-black">{u.role}</td>
<td className="p-4 text-right flex justify-end gap-2">
<button onClick={() => { setEditingUserId(u.id); setNewUser({...newUser, username:u.username, email:u.email||'', role:u.role}); }} className="p-2 text-cyan-400 hover:bg-cyan-900/20 rounded"><EditIcon className="w-4 h-4"/></button>
{u.id !== 1 && <button onClick={() => deleteUser(u.id).then(loadAllData)} className="p-2 text-red-400 hover:bg-red-900/20 rounded"><TrashIcon className="w-4 h-4"/></button>}
</td>
</tr>))}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'groups' && (
<div className="space-y-6 animate-in fade-in duration-300">
<form onSubmit={handleCreateGroup} className="bg-slate-900/50 p-6 rounded-2xl border border-slate-700 flex flex-col md:flex-row gap-4">
<input placeholder="Group Name" required value={newGroup.groupName} onChange={e => setNewGroup({...newGroup, groupName: e.target.value})} className="input-style flex-1" />
<input placeholder="Emails (comma separated)" required value={newGroup.emails} onChange={e => setNewGroup({...newGroup, emails: e.target.value})} className="input-style flex-[2]" />
<button type="submit" className="primary-btn px-8">Add Group</button>
</form>
<div className="rounded-xl border border-slate-700 bg-slate-900/20">
<table className="w-full text-left text-sm">
<thead className="bg-slate-900 text-slate-500 uppercase text-[10px] font-black"><tr><th className="p-4">Name</th><th className="p-4">Recipients</th><th className="p-4 text-right">Actions</th></tr></thead>
<tbody>{groups.map(g => (
<tr key={g.id} className="border-b border-slate-700/50 hover:bg-slate-700/30"><td className="p-4 text-white font-bold">{g.groupName}</td><td className="p-4 text-xs font-mono text-slate-400 truncate max-w-md">{g.emails}</td><td className="p-4 text-right"><button onClick={() => deleteGroup(g.id).then(loadAllData)} className="p-2 text-red-400 hover:bg-red-900/20 rounded"><TrashIcon className="w-4 h-4"/></button></td></tr>))}
</tbody></table>
</div>
</div>
)}
{activeTab === 'vendors' && (
<div className="space-y-6 animate-in fade-in duration-300">
<form onSubmit={handleCreateVendor} className="bg-slate-900/50 p-6 rounded-2xl border border-slate-700 grid grid-cols-1 md:grid-cols-2 gap-4">
<input placeholder="Vendor Name" required value={newVendor.vendorName} onChange={e => setNewVendor({...newVendor, vendorName: e.target.value})} className="input-style" />
<input placeholder="Contact Name" value={newVendor.contactName} onChange={e => setNewVendor({...newVendor, contactName: e.target.value})} className="input-style" />
<input placeholder="Contact Email" value={newVendor.contactEmail} onChange={e => setNewVendor({...newVendor, contactEmail: e.target.value})} className="input-style" />
<div className="flex gap-2">
<input placeholder="Contact Phone" value={newVendor.contactPhone} onChange={e => setNewVendor({...newVendor, contactPhone: e.target.value})} className="input-style flex-1" />
<button type="submit" className="primary-btn px-6">Save Vendor</button>
</div>
</form>
<div className="rounded-xl border border-slate-700 bg-slate-900/20 overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-900 text-slate-500 uppercase text-[10px] font-black"><tr><th className="p-4">Vendor</th><th className="p-4">Contact</th><th className="p-4">Email</th><th className="p-4 text-right">Actions</th></tr></thead>
<tbody>{contacts.map(c => (
<tr key={c.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="p-4 text-white font-bold">{c.vendorName}</td>
<td className="p-4 text-slate-300">{c.contactName || '---'}</td>
<td className="p-4 text-xs font-mono text-cyan-400">{c.contactEmail || '---'}</td>
<td className="p-4 text-right"><button onClick={() => deleteContact(c.id!).then(loadAllData)} className="p-2 text-red-400 hover:bg-red-900/20 rounded"><TrashIcon className="w-4 h-4"/></button></td>
</tr>))}
</tbody></table>
</div>
</div>
)}
</div>
<style>{`
.input-style { background: #1e293b; border: 1px solid #334155; color: #e5e7eb; border-radius: 12px; padding: 12px 16px; width: 100%; transition: all 0.2s; font-size: 0.875rem; }
.input-style:focus { outline: none; border-color: #22d3ee; box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.1); }
.label-style { display: block; font-size: 10px; font-weight: 900; color: #64748b; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 4px; margin-left: 4px; }
.primary-btn { background: #0891b2; color: white; padding: 12px 24px; border-radius: 12px; font-weight: 700; transition: all 0.2s; border: none; cursor: pointer; font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.05em; }
.primary-btn:hover { background: #06b6d4; transform: translateY(-1px); }
.primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
`}</style>
</div>
);
};
export default AdminSettings;