This commit is contained in:
jpmvaz
2026-09-13 20:21:31 +01:00
commit 95b47a69e3
29 changed files with 2608 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './AuthContext.jsx'
import Login from './pages/Login.jsx'
import Dashboard from './pages/Dashboard.jsx'
import Admin from './pages/Admin.jsx'
import Profile from './pages/Profile.jsx'
import Fields from './pages/Fields.jsx'
import Maintenance from './pages/Maintenance.jsx'
import Layout from './components/Layout.jsx'
import ChangePassword from './pages/ChangePassword.jsx'
function ProtectedRoute({ children, adminOnly = false }) {
const { user } = useAuth()
if (!user) return <Navigate to="/login" replace />
if (user.must_change_password) return <Navigate to="/change-password" replace />
if (adminOnly && user.role !== 'admin') return <Navigate to="/" replace />
return children
}
function AppRoutes() {
const { user } = useAuth()
return (
<Routes>
<Route path="/login" element={!user ? <Login /> : <Navigate to="/" replace />} />
<Route path="/change-password" element={user ? <ChangePassword /> : <Navigate to="/login" replace />} />
<Route path="/" element={<ProtectedRoute><Layout><Dashboard /></Layout></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute adminOnly><Layout><Admin /></Layout></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><Layout><Profile /></Layout></ProtectedRoute>} />
<Route path="/fields" element={<ProtectedRoute adminOnly><Layout><Fields /></Layout></ProtectedRoute>} />
<Route path="/maintenance" element={<ProtectedRoute adminOnly><Layout><Maintenance /></Layout></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AuthProvider>
)
}
+38
View File
@@ -0,0 +1,38 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(() => {
try { return JSON.parse(localStorage.getItem('user')) } catch { return null }
})
const [token, setToken] = useState(() => localStorage.getItem('token'))
const login = (userData, tokenData) => {
setUser(userData)
setToken(tokenData)
localStorage.setItem('user', JSON.stringify(userData))
localStorage.setItem('token', tokenData)
}
const logout = () => {
setUser(null)
setToken(null)
localStorage.removeItem('user')
localStorage.removeItem('token')
}
const updateUser = (updates) => {
const updated = { ...user, ...updates }
setUser(updated)
localStorage.setItem('user', JSON.stringify(updated))
}
return (
<AuthContext.Provider value={{ user, token, login, logout, updateUser, isAdmin: user?.role === 'admin' }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)
+23
View File
@@ -0,0 +1,23 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(err)
}
)
export default api
+118
View File
@@ -0,0 +1,118 @@
import React, { useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useAuth } from '../AuthContext.jsx'
import { LayoutDashboard, Settings, User, LogOut, Menu, X, Smartphone, Columns, Wrench } from 'lucide-react'
export default function Layout({ children }) {
const { user, logout, isAdmin } = useAuth()
const location = useLocation()
const [mobileOpen, setMobileOpen] = useState(false)
const navItems = [
{ path: '/', icon: LayoutDashboard, label: 'Dashboard' },
...(isAdmin ? [
{ path: '/admin', icon: Settings, label: 'Admin Panel' },
] : []),
{ path: '/profile', icon: User, label: 'Profile' },
...(isAdmin ? [
{ path: '/fields', icon: Columns, label: 'Fields' },
{ path: '/maintenance', icon: Wrench, label: 'Maintenance' },
] : []),
]
const NavLinks = () => (
<>
{navItems.map(({ path, icon: Icon, label }) => (
<Link
key={path}
to={path}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
location.pathname === path
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-900/40'
: 'text-slate-400 hover:text-white hover:bg-slate-700/60'
}`}
>
<Icon size={20} />
<span className="font-medium">{label}</span>
</Link>
))}
</>
)
return (
<div className="flex min-h-screen bg-slate-950">
{/* Sidebar desktop */}
<aside className="hidden lg:flex flex-col w-64 bg-slate-900 border-r border-slate-800 p-4">
<div className="flex items-center gap-3 px-4 py-4 mb-6">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<Smartphone size={20} className="text-white" />
</div>
<div>
<h1 className="font-bold text-white text-sm leading-tight">Mobile Contract</h1>
<p className="text-xs text-slate-400">Manager</p>
</div>
</div>
<nav className="flex flex-col gap-1 flex-1">
<NavLinks />
</nav>
<div className="border-t border-slate-800 pt-4">
<div className="px-4 py-2 mb-2">
<p className="text-sm font-medium text-white truncate">{user?.username}</p>
<span className={`text-xs px-2 py-0.5 rounded-full ${isAdmin ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-700 text-slate-400'}`}>
{user?.role}
</span>
</div>
<button
onClick={logout}
className="flex items-center gap-3 px-4 py-3 rounded-xl w-full text-slate-400 hover:text-white hover:bg-slate-700/60 transition-all"
>
<LogOut size={20} />
<span className="font-medium">Logout</span>
</button>
</div>
</aside>
{/* Mobile sidebar */}
{mobileOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileOpen(false)} />
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-slate-900 border-r border-slate-800 p-4 flex flex-col">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<Smartphone size={20} className="text-white" />
</div>
<div>
<h1 className="font-bold text-white text-sm">Mobile Contract</h1>
<p className="text-xs text-slate-400">Manager</p>
</div>
</div>
<button onClick={() => setMobileOpen(false)} className="text-slate-400 hover:text-white">
<X size={20} />
</button>
</div>
<nav className="flex flex-col gap-1 flex-1"><NavLinks /></nav>
<div className="border-t border-slate-800 pt-4">
<button onClick={logout} className="flex items-center gap-3 px-4 py-3 rounded-xl w-full text-slate-400 hover:text-white hover:bg-slate-700/60">
<LogOut size={20} /><span>Logout</span>
</button>
</div>
</aside>
</div>
)}
{/* Main content */}
<div className="flex-1 flex flex-col">
<header className="lg:hidden flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800">
<button onClick={() => setMobileOpen(true)} className="text-slate-400 hover:text-white">
<Menu size={24} />
</button>
<h1 className="text-white font-bold">Mobile Contract Manager</h1>
<div className="w-6" />
</header>
<main className="flex-1 p-6 overflow-auto">{children}</main>
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #0f172a;
color: #e2e8f0;
font-family: 'Inter', system-ui, sans-serif;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+267
View File
@@ -0,0 +1,267 @@
import React, { useState, useEffect } from 'react'
import api from '../api.js'
import { useAuth } from '../AuthContext.jsx'
import { Plus, Trash2, Edit2, X, Check, Users, Shield, User, KeyRound } from 'lucide-react'
function UserModal({ user, onClose, onSave }) {
const [form, setForm] = useState({
username: user?.username || '',
password: '',
role: user?.role || 'user',
})
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
if (user) {
const payload = { role: form.role }
if (form.username !== user.username) payload.username = form.username
if (form.password) payload.password = form.password
const { data } = await api.put(`/users/${user.id}`, payload)
onSave(data, 'edit')
} else {
if (!form.password) return setError('Password is required')
const { data } = await api.post('/users', form)
onSave(data, 'add')
}
onClose()
} catch (err) {
setError(err.response?.data?.error || 'Failed to save user')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 w-full max-w-md shadow-2xl">
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h3 className="text-lg font-bold text-white">{user ? 'Edit User' : 'New User'}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white"><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3">{error}</div>}
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Username</label>
<input
type="text"
value={form.username}
onChange={e => setForm({ ...form, username: e.target.value })}
required
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder="Username"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">
{user ? 'New Password (leave blank to keep current)' : 'Password'}
</label>
<input
type="password"
value={form.password}
onChange={e => setForm({ ...form, password: e.target.value })}
required={!user}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder={user ? '••••••••' : 'Min. 6 characters'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Role</label>
<div className="grid grid-cols-2 gap-3">
{['user', 'admin'].map(role => (
<button
type="button"
key={role}
onClick={() => setForm({ ...form, role })}
className={`py-3 rounded-xl border text-sm font-semibold flex items-center justify-center gap-2 transition ${
form.role === role
? role === 'admin' ? 'border-amber-500 bg-amber-500/10 text-amber-400' : 'border-indigo-500 bg-indigo-500/10 text-indigo-400'
: 'border-slate-700 text-slate-400 hover:border-slate-600'
}`}
>
{role === 'admin' ? <Shield size={16} /> : <User size={16} />}
{role.charAt(0).toUpperCase() + role.slice(1)}
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm font-medium">Cancel</button>
<button type="submit" disabled={loading} className="flex-1 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold disabled:opacity-50 transition">
{loading ? 'Saving...' : 'Save User'}
</button>
</div>
</form>
</div>
</div>
)
}
export default function Admin() {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [modal, setModal] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [toast, setToast] = useState('')
useEffect(() => {
api.get('/users').then(({ data }) => { setUsers(data); setLoading(false) }).catch(() => setLoading(false))
}, [])
const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(''), 3000) }
const handleSave = (data, type) => {
if (type === 'add') setUsers(prev => [data, ...prev])
else setUsers(prev => prev.map(u => u.id === data.id ? data : u))
showToast(type === 'add' ? 'User created' : 'User updated')
}
const handleDelete = async (id) => {
try {
await api.delete(`/users/${id}`)
setUsers(prev => prev.filter(u => u.id !== id))
setDeleteConfirm(null)
showToast('User deleted')
} catch (err) {
showToast(err.response?.data?.error || 'Failed to delete')
}
}
const adminCount = users.filter(u => u.role === 'admin').length
const userCount = users.filter(u => u.role === 'user').length
return (
<div>
{toast && (
<div className="fixed top-4 right-4 z-50 bg-green-500/20 border border-green-500/40 text-green-300 px-4 py-3 rounded-xl shadow-xl text-sm font-medium flex items-center gap-2">
<Check size={16} />{toast}
</div>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-white">Admin Panel</h2>
<p className="text-slate-400 text-sm mt-1">Manage user accounts and permissions</p>
</div>
<button
onClick={() => setModal('add')}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2.5 rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-900/40"
>
<Plus size={16} />Add User
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
{[
{ label: 'Total Users', value: users.length, icon: Users, color: 'indigo' },
{ label: 'Admins', value: adminCount, icon: Shield, color: 'amber' },
{ label: 'Regular Users', value: userCount, icon: User, color: 'blue' },
].map(({ label, value, icon: Icon, color }) => (
<div key={label} className="bg-slate-900 rounded-xl border border-slate-800 p-4 flex items-center gap-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center bg-${color}-500/20`}>
<Icon size={20} className={`text-${color}-400`} />
</div>
<div>
<p className="text-2xl font-bold text-white">{value}</p>
<p className="text-xs text-slate-400">{label}</p>
</div>
</div>
))}
</div>
{/* Users table */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden">
{loading ? (
<div className="p-12 text-center text-slate-500">Loading...</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-slate-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Username</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Role</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">MFA</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Created</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-b border-slate-800/50 hover:bg-slate-800/30 transition">
<td className="px-4 py-3.5">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${u.role === 'admin' ? 'bg-amber-500/20' : 'bg-indigo-500/20'}`}>
{u.role === 'admin' ? <Shield size={14} className="text-amber-400" /> : <User size={14} className="text-indigo-400" />}
</div>
<span className="text-white font-medium">{u.username}</span>
{u.id === currentUser.id && <span className="text-xs text-slate-500">(you)</span>}
</div>
</td>
<td className="px-4 py-3.5">
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${u.role === 'admin' ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-700 text-slate-300'}`}>
{u.role}
</span>
</td>
<td className="px-4 py-3.5">
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${u.mfa_enabled ? 'bg-green-500/20 text-green-400' : 'bg-slate-700/50 text-slate-500'}`}>
{u.mfa_enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td className="px-4 py-3.5">
{u.must_change_password ? (
<span className="text-xs px-2.5 py-1 rounded-full font-medium bg-amber-500/20 text-amber-400 flex items-center gap-1 w-fit">
<KeyRound size={10} />Must change pw
</span>
) : (
<span className="text-xs text-green-400">Active</span>
)}
</td>
<td className="px-4 py-3.5 text-slate-400 text-sm">{new Date(u.created_at).toLocaleDateString()}</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => setModal(u)} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition">
<Edit2 size={14} />
</button>
{u.id !== currentUser.id && (
<button onClick={() => setDeleteConfirm(u)} className="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition">
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{modal && (
<UserModal
user={modal === 'add' ? null : modal}
onClose={() => setModal(null)}
onSave={handleSave}
/>
)}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 w-full max-w-sm shadow-2xl">
<h3 className="text-lg font-bold text-white mb-2">Delete User</h3>
<p className="text-slate-400 text-sm mb-6">Delete <strong className="text-white">{deleteConfirm.username}</strong>? This cannot be undone.</p>
<div className="flex gap-3">
<button onClick={() => setDeleteConfirm(null)} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm">Cancel</button>
<button onClick={() => handleDelete(deleteConfirm.id)} className="flex-1 py-2.5 rounded-xl bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30 transition text-sm font-semibold">Delete</button>
</div>
</div>
</div>
)}
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../AuthContext.jsx'
import api from '../api.js'
import { KeyRound, Eye, EyeOff } from 'lucide-react'
export default function ChangePassword() {
const { user, updateUser, logout } = useAuth()
const navigate = useNavigate()
const [form, setForm] = useState({ new_password: '', confirm: '' })
const [showPw, setShowPw] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const isForced = user?.must_change_password
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (form.new_password !== form.confirm) return setError('Passwords do not match')
if (form.new_password.length < 6) return setError('Password must be at least 6 characters')
setLoading(true)
try {
await api.post('/auth/change-password', { new_password: form.new_password })
updateUser({ must_change_password: false })
navigate('/')
} catch (err) {
setError(err.response?.data?.error || 'Failed to change password')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-8 shadow-2xl">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-amber-500/20 rounded-xl flex items-center justify-center">
<KeyRound size={24} className="text-amber-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">
{isForced ? 'Change Required' : 'Change Password'}
</h2>
{isForced && <p className="text-sm text-amber-400">You must set a new password before continuing.</p>}
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3 mb-4">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">New Password</label>
<div className="relative">
<input
type={showPw ? 'text' : 'password'}
value={form.new_password}
onChange={e => setForm({ ...form, new_password: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 pr-12 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder="Min. 6 characters"
required
/>
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Confirm Password</label>
<input
type={showPw ? 'text' : 'password'}
value={form.confirm}
onChange={e => setForm({ ...form, confirm: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder="Repeat password"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-all mt-2"
>
{loading ? 'Saving...' : 'Set New Password'}
</button>
{!isForced && (
<button type="button" onClick={() => navigate(-1)} className="w-full text-slate-400 hover:text-white text-sm py-2 transition">
Cancel
</button>
)}
{isForced && (
<button type="button" onClick={logout} className="w-full text-slate-500 hover:text-slate-300 text-sm py-2 transition">
Logout instead
</button>
)}
</form>
</div>
</div>
</div>
)
}
+278
View File
@@ -0,0 +1,278 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useAuth } from '../AuthContext.jsx'
import api from '../api.js'
import { Search, Plus, Edit2, Trash2, X, Check, Database, ChevronUp, ChevronDown } from 'lucide-react'
function ContractModal({ contract, fields, onClose, onSave }) {
const emptyForm = () => fields.reduce((acc, f) => ({ ...acc, [f.field_key]: '' }), {})
const [form, setForm] = useState(contract ? { ...emptyForm(), ...contract.data } : emptyForm())
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
if (contract) {
const { data } = await api.put(`/contracts/${contract.id}`, { data: form })
onSave(data, 'edit')
} else {
const { data } = await api.post('/contracts', { data: form })
onSave(data, 'add')
}
onClose()
} catch (err) {
setError(err.response?.data?.error || 'Failed to save')
} finally {
setLoading(false)
}
}
const inputType = (type) => ({ number: 'number', phone: 'tel', email: 'email', text: 'text' }[type] || 'text')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 w-full max-w-lg shadow-2xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h3 className="text-lg font-bold text-white">{contract ? 'Edit Contract' : 'New Contract'}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white"><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
{error && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3">{error}</div>}
{fields.length === 0 ? (
<p className="text-slate-400 text-sm text-center py-4">No fields defined. Go to Fields to add some.</p>
) : (
<div className="grid grid-cols-2 gap-4">
{fields.map(f => (
<div key={f.field_key} className={fields.length === 1 ? 'col-span-2' : ''}>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
{f.label}{f.required && <span className="text-red-400 ml-1">*</span>}
</label>
<input
type={inputType(f.field_type)}
step={f.field_type === 'number' ? 'any' : undefined}
value={form[f.field_key] || ''}
onChange={e => setForm({ ...form, [f.field_key]: e.target.value })}
required={f.required}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-3 py-2.5 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder={f.label}
/>
</div>
))}
</div>
)}
</form>
<div className="flex gap-3 p-6 border-t border-slate-800">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white hover:border-slate-600 transition text-sm font-medium">
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || fields.length === 0}
className="flex-1 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold disabled:opacity-50 transition"
>
{loading ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)
}
export default function Dashboard() {
const { isAdmin } = useAuth()
const [contracts, setContracts] = useState([])
const [fields, setFields] = useState([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [modal, setModal] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [sortField, setSortField] = useState(null)
const [sortDir, setSortDir] = useState('asc')
const [toast, setToast] = useState('')
useEffect(() => {
api.get('/fields').then(({ data }) => setFields(data)).catch(() => {})
}, [])
const fetchContracts = useCallback(async () => {
try {
const { data } = await api.get('/contracts', { params: search ? { search } : {} })
setContracts(data)
} catch {}
setLoading(false)
}, [search])
useEffect(() => {
const t = setTimeout(fetchContracts, 300)
return () => clearTimeout(t)
}, [fetchContracts])
const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(''), 3000) }
const handleSave = (data, type) => {
if (type === 'add') setContracts(prev => [data, ...prev])
else setContracts(prev => prev.map(c => c.id === data.id ? data : c))
showToast(type === 'add' ? 'Contract added' : 'Contract updated')
}
const handleDelete = async (id) => {
try {
await api.delete(`/contracts/${id}`)
setContracts(prev => prev.filter(c => c.id !== id))
setDeleteConfirm(null)
showToast('Contract deleted')
} catch { showToast('Failed to delete') }
}
const handleSort = (key) => {
if (sortField === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortField(key); setSortDir('asc') }
}
const sorted = [...contracts].sort((a, b) => {
if (!sortField) return 0
const av = a.data?.[sortField] ?? ''
const bv = b.data?.[sortField] ?? ''
return sortDir === 'asc' ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1)
})
const SortIcon = ({ fieldKey }) => {
if (sortField !== fieldKey) return <ChevronUp size={14} className="inline ml-1 opacity-20" />
return sortDir === 'asc'
? <ChevronUp size={14} className="inline ml-1 text-indigo-400" />
: <ChevronDown size={14} className="inline ml-1 text-indigo-400" />
}
return (
<div>
{toast && (
<div className="fixed top-4 right-4 z-50 bg-green-500/20 border border-green-500/40 text-green-300 px-4 py-3 rounded-xl shadow-xl text-sm font-medium flex items-center gap-2">
<Check size={16} />{toast}
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div>
<h2 className="text-2xl font-bold text-white">Mobile Contracts</h2>
<p className="text-slate-400 text-sm mt-1">{contracts.length} record{contracts.length !== 1 ? 's' : ''}</p>
</div>
<div className="flex gap-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search all fields..."
className="bg-slate-800 border border-slate-700 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 w-60 transition"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
<X size={14} />
</button>
)}
</div>
{isAdmin && (
<button
onClick={() => setModal('add')}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2.5 rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-900/40"
>
<Plus size={16} />Add
</button>
)}
</div>
</div>
<div className="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden">
{loading ? (
<div className="p-12 text-center text-slate-500">Loading...</div>
) : fields.length === 0 ? (
<div className="p-12 text-center">
<Database size={48} className="text-slate-700 mx-auto mb-3" />
<p className="text-slate-400">No fields defined yet.</p>
{isAdmin && <p className="text-slate-500 text-sm mt-1">Go to <strong className="text-slate-400">Fields</strong> in the sidebar to add some.</p>}
</div>
) : sorted.length === 0 ? (
<div className="p-12 text-center">
<Database size={48} className="text-slate-700 mx-auto mb-3" />
<p className="text-slate-400">{search ? 'No results found' : 'No contracts yet'}</p>
{isAdmin && !search && (
<button onClick={() => setModal('add')} className="mt-3 text-indigo-400 hover:text-indigo-300 text-sm">
Add the first one
</button>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-800">
{fields.map(f => (
<th
key={f.field_key}
onClick={() => handleSort(f.field_key)}
className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider cursor-pointer hover:text-white transition whitespace-nowrap select-none"
>
{f.label}<SortIcon fieldKey={f.field_key} />
</th>
))}
{isAdmin && <th className="px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Actions</th>}
</tr>
</thead>
<tbody>
{sorted.map((c, i) => (
<tr key={c.id} className={`border-b border-slate-800/50 hover:bg-slate-800/30 transition ${i % 2 === 0 ? '' : 'bg-slate-800/10'}`}>
{fields.map(f => (
<td key={f.field_key} className="px-4 py-3.5 text-slate-300 whitespace-nowrap">
{c.data?.[f.field_key] != null && c.data?.[f.field_key] !== ''
? String(c.data[f.field_key])
: <span className="text-slate-600"></span>
}
</td>
))}
{isAdmin && (
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => setModal(c)} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition">
<Edit2 size={14} />
</button>
<button onClick={() => setDeleteConfirm(c.id)} className="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition">
<Trash2 size={14} />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{modal && (
<ContractModal
contract={modal === 'add' ? null : modal}
fields={fields}
onClose={() => setModal(null)}
onSave={handleSave}
/>
)}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 w-full max-w-sm shadow-2xl">
<h3 className="text-lg font-bold text-white mb-2">Confirm Delete</h3>
<p className="text-slate-400 text-sm mb-6">This contract will be permanently deleted.</p>
<div className="flex gap-3">
<button onClick={() => setDeleteConfirm(null)} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm">Cancel</button>
<button onClick={() => handleDelete(deleteConfirm)} className="flex-1 py-2.5 rounded-xl bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30 transition text-sm font-semibold">Delete</button>
</div>
</div>
</div>
)}
</div>
)
}
+307
View File
@@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react'
import api from '../api.js'
import {
Plus, Trash2, Edit2, X, Check, GripVertical,
Type, Hash, Phone, Mail, AlertTriangle, Columns
} from 'lucide-react'
const TYPE_OPTIONS = [
{ value: 'text', label: 'Text', icon: Type },
{ value: 'number', label: 'Number', icon: Hash },
{ value: 'phone', label: 'Phone', icon: Phone },
{ value: 'email', label: 'Email', icon: Mail },
]
const TypeIcon = ({ type, size = 14 }) => {
const opt = TYPE_OPTIONS.find(t => t.value === type)
const Icon = opt?.icon || Type
return <Icon size={size} />
}
function FieldModal({ field, onClose, onSave }) {
const [form, setForm] = useState({
label: field?.label || '',
field_type: field?.field_type || 'text',
required: field?.required || false,
})
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!form.label.trim()) return setError('Label is required')
setLoading(true)
try {
if (field) {
const { data } = await api.put(`/fields/${field.id}`, form)
onSave(data, 'edit')
} else {
const { data } = await api.post('/fields', form)
onSave(data, 'add')
}
onClose()
} catch (err) {
setError(err.response?.data?.error || 'Failed to save field')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 w-full max-w-md shadow-2xl">
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h3 className="text-lg font-bold text-white">{field ? 'Edit Field' : 'New Field'}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white"><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{error && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3">{error}</div>}
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Field Label <span className="text-red-400">*</span></label>
<input
type="text"
value={form.label}
onChange={e => setForm({ ...form, label: e.target.value })}
autoFocus
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder="e.g. First Name, Contract ID..."
/>
{!field && form.label && (
<p className="text-xs text-slate-500 mt-1.5">
Key: <code className="text-slate-400">{form.label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')}</code>
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-2">Field Type</label>
<div className="grid grid-cols-4 gap-2">
{TYPE_OPTIONS.map(({ value, label, icon: Icon }) => (
<button
type="button"
key={value}
onClick={() => setForm({ ...form, field_type: value })}
className={`flex flex-col items-center gap-1.5 py-3 px-2 rounded-xl border text-xs font-medium transition ${
form.field_type === value
? 'border-indigo-500 bg-indigo-500/10 text-indigo-400'
: 'border-slate-700 text-slate-400 hover:border-slate-600 hover:text-white'
}`}
>
<Icon size={16} />
{label}
</button>
))}
</div>
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer group">
<div
onClick={() => setForm({ ...form, required: !form.required })}
className={`w-11 h-6 rounded-full transition-colors relative ${form.required ? 'bg-indigo-600' : 'bg-slate-700'}`}
>
<div className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${form.required ? 'translate-x-5' : ''}`} />
</div>
<span className="text-sm font-medium text-slate-300 group-hover:text-white transition">Required field</span>
</label>
<p className="text-xs text-slate-500 mt-1 ml-14">Users must fill this field when adding a contract</p>
</div>
<div className="flex gap-3 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm font-medium">Cancel</button>
<button type="submit" disabled={loading} className="flex-1 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold disabled:opacity-50 transition">
{loading ? 'Saving...' : field ? 'Save Changes' : 'Add Field'}
</button>
</div>
</form>
</div>
</div>
)
}
export default function Fields() {
const [fields, setFields] = useState([])
const [loading, setLoading] = useState(true)
const [modal, setModal] = useState(null) // null | 'add' | field object
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [toast, setToast] = useState('')
const [dragIdx, setDragIdx] = useState(null)
const [dragOverIdx, setDragOverIdx] = useState(null)
useEffect(() => {
api.get('/fields').then(({ data }) => { setFields(data); setLoading(false) }).catch(() => setLoading(false))
}, [])
const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(''), 3000) }
const handleSave = (data, type) => {
if (type === 'add') setFields(prev => [...prev, data])
else setFields(prev => prev.map(f => f.id === data.id ? data : f))
showToast(type === 'add' ? 'Field added' : 'Field updated')
}
const handleDelete = async (field) => {
try {
await api.delete(`/fields/${field.id}`)
setFields(prev => prev.filter(f => f.id !== field.id))
setDeleteConfirm(null)
showToast('Field deleted')
} catch (err) {
showToast(err.response?.data?.error || 'Failed to delete')
}
}
// Drag-to-reorder
const handleDragStart = (idx) => setDragIdx(idx)
const handleDragOver = (e, idx) => { e.preventDefault(); setDragOverIdx(idx) }
const handleDrop = async (idx) => {
if (dragIdx === null || dragIdx === idx) { setDragIdx(null); setDragOverIdx(null); return }
const reordered = [...fields]
const [moved] = reordered.splice(dragIdx, 1)
reordered.splice(idx, 0, moved)
const withOrder = reordered.map((f, i) => ({ ...f, sort_order: i + 1 }))
setFields(withOrder)
setDragIdx(null)
setDragOverIdx(null)
try {
await api.put('/fields/reorder', { order: withOrder.map(f => ({ id: f.id, sort_order: f.sort_order })) })
} catch { showToast('Failed to save order') }
}
return (
<div className="max-w-3xl">
{toast && (
<div className="fixed top-4 right-4 z-50 bg-green-500/20 border border-green-500/40 text-green-300 px-4 py-3 rounded-xl shadow-xl text-sm font-medium flex items-center gap-2">
<Check size={16} />{toast}
</div>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-white">Fields</h2>
<p className="text-slate-400 text-sm mt-1">Manage the columns shown in the Dashboard</p>
</div>
<button
onClick={() => setModal('add')}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2.5 rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-900/40"
>
<Plus size={16} />Add Field
</button>
</div>
{/* Info banner */}
<div className="bg-indigo-500/10 border border-indigo-500/20 rounded-xl px-4 py-3 mb-5 flex items-start gap-3">
<Columns size={16} className="text-indigo-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-indigo-300">
Fields define the columns in the Dashboard. You can drag rows to reorder them. Deleting a field removes its data from all existing contracts.
</p>
</div>
<div className="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden">
{loading ? (
<div className="p-12 text-center text-slate-500">Loading...</div>
) : fields.length === 0 ? (
<div className="p-12 text-center">
<Columns size={48} className="text-slate-700 mx-auto mb-3" />
<p className="text-slate-400 mb-1">No fields defined yet</p>
<button onClick={() => setModal('add')} className="text-indigo-400 hover:text-indigo-300 text-sm mt-2">
Add your first field
</button>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-slate-800">
<th className="w-10 px-4 py-3"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Label</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Key</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Type</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Required</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{fields.map((f, idx) => (
<tr
key={f.id}
draggable
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={() => handleDrop(idx)}
onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }}
className={`border-b border-slate-800/50 transition ${
dragOverIdx === idx && dragIdx !== idx ? 'bg-indigo-500/10' : 'hover:bg-slate-800/30'
} ${dragIdx === idx ? 'opacity-40' : ''}`}
>
<td className="px-4 py-3.5">
<GripVertical size={16} className="text-slate-600 cursor-grab active:cursor-grabbing" />
</td>
<td className="px-4 py-3.5">
<span className="text-white font-medium">{f.label}</span>
</td>
<td className="px-4 py-3.5">
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-1 rounded-lg">{f.field_key}</code>
</td>
<td className="px-4 py-3.5">
<div className="flex items-center gap-1.5 text-slate-400 text-sm">
<TypeIcon type={f.field_type} />
<span className="capitalize">{f.field_type}</span>
</div>
</td>
<td className="px-4 py-3.5">
{f.required
? <span className="text-xs px-2.5 py-1 rounded-full bg-amber-500/20 text-amber-400 font-medium">Required</span>
: <span className="text-xs text-slate-600">Optional</span>
}
</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => setModal(f)} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition">
<Edit2 size={14} />
</button>
<button onClick={() => setDeleteConfirm(f)} className="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition">
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{modal && (
<FieldModal
field={modal === 'add' ? null : modal}
onClose={() => setModal(null)}
onSave={handleSave}
/>
)}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 w-full max-w-sm shadow-2xl">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 bg-red-500/20 rounded-xl flex items-center justify-center flex-shrink-0">
<AlertTriangle size={20} className="text-red-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Delete Field</h3>
<p className="text-slate-400 text-sm mt-1">
Delete <strong className="text-white">"{deleteConfirm.label}"</strong>? This will permanently remove this column and all its data from every contract.
</p>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setDeleteConfirm(null)} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm">Cancel</button>
<button onClick={() => handleDelete(deleteConfirm)} className="flex-1 py-2.5 rounded-xl bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30 transition text-sm font-semibold">Delete Field</button>
</div>
</div>
</div>
)}
</div>
)
}
+140
View File
@@ -0,0 +1,140 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../AuthContext.jsx'
import api from '../api.js'
import { Smartphone, Eye, EyeOff, Shield } from 'lucide-react'
export default function Login() {
const [form, setForm] = useState({ username: '', password: '', mfa_code: '' })
const [step, setStep] = useState('credentials') // 'credentials' | 'mfa'
const [showPw, setShowPw] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const payload = { username: form.username, password: form.password }
if (step === 'mfa') payload.mfa_code = form.mfa_code
const { data } = await api.post('/auth/login', payload)
if (data.mfa_required) {
setStep('mfa')
setLoading(false)
return
}
login(data.user, data.token)
if (data.user.must_change_password) {
navigate('/change-password')
} else {
navigate('/')
}
} catch (err) {
setError(err.response?.data?.error || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-xl shadow-indigo-900/50">
<Smartphone size={32} className="text-white" />
</div>
<h1 className="text-3xl font-bold text-white">Mobile Contract</h1>
<p className="text-slate-400 mt-1">Manager</p>
</div>
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-8 shadow-2xl">
<h2 className="text-xl font-semibold text-white mb-6">
{step === 'mfa' ? 'Two-Factor Authentication' : 'Sign In'}
</h2>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3 mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{step === 'credentials' ? (
<>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Username</label>
<input
type="text"
autoComplete="username"
value={form.username}
onChange={e => setForm({ ...form, username: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder="Enter your username"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Password</label>
<div className="relative">
<input
type={showPw ? 'text' : 'password'}
autoComplete="current-password"
value={form.password}
onChange={e => setForm({ ...form, password: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 pr-12 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder="Enter your password"
required
/>
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
</>
) : (
<div>
<div className="flex items-center gap-3 mb-4 p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-xl">
<Shield size={20} className="text-indigo-400 flex-shrink-0" />
<p className="text-sm text-indigo-300">Enter the 6-digit code from your authenticator app.</p>
</div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Authentication Code</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
value={form.mfa_code}
onChange={e => setForm({ ...form, mfa_code: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white text-center text-2xl tracking-widest placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder="000000"
required
autoFocus
/>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-all mt-2 shadow-lg shadow-indigo-900/40"
>
{loading ? 'Signing in...' : step === 'mfa' ? 'Verify' : 'Sign In'}
</button>
{step === 'mfa' && (
<button type="button" onClick={() => setStep('credentials')} className="w-full text-slate-400 hover:text-white text-sm py-2 transition">
Back
</button>
)}
</form>
</div>
</div>
</div>
)
}
+317
View File
@@ -0,0 +1,317 @@
import React, { useState, useRef } from 'react'
import api from '../api.js'
import {
Download, Upload, Database, AlertTriangle, Check,
RefreshCw, FileJson, ShieldAlert, Info
} from 'lucide-react'
export default function Maintenance() {
const [exporting, setExporting] = useState(false)
const [importing, setImporting] = useState(false)
const [importMode, setImportMode] = useState('merge')
const [selectedFile, setSelectedFile] = useState(null)
const [previewData, setPreviewData] = useState(null)
const [previewError, setPreviewError] = useState('')
const [result, setResult] = useState(null)
const [error, setError] = useState('')
const fileRef = useRef()
// --- Export ---
const handleExport = async () => {
setExporting(true)
setError('')
try {
const { data } = await api.get('/maintenance/backup', { responseType: 'blob' })
const url = URL.createObjectURL(new Blob([data], { type: 'application/json' }))
const a = document.createElement('a')
a.href = url
a.download = `backup-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
} catch {
setError('Export failed. Please try again.')
} finally {
setExporting(false)
}
}
// --- File selection & preview ---
const handleFileChange = (e) => {
const file = e.target.files[0]
setSelectedFile(file || null)
setPreviewData(null)
setPreviewError('')
setResult(null)
setError('')
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result)
if (!parsed.fields || !parsed.contracts) {
setPreviewError('Invalid backup file: missing fields or contracts data.')
return
}
setPreviewData(parsed)
} catch {
setPreviewError('Could not parse file — make sure it is a valid JSON backup.')
}
}
reader.readAsText(file)
}
// --- Import ---
const handleImport = async () => {
if (!selectedFile || !previewData) return
setImporting(true)
setError('')
setResult(null)
const formData = new FormData()
formData.append('backup', selectedFile)
formData.append('mode', importMode)
try {
const { data } = await api.post('/maintenance/restore', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
setResult(data)
setSelectedFile(null)
setPreviewData(null)
if (fileRef.current) fileRef.current.value = ''
} catch (err) {
setError(err.response?.data?.error || 'Import failed. Please try again.')
} finally {
setImporting(false)
}
}
const clearFile = () => {
setSelectedFile(null)
setPreviewData(null)
setPreviewError('')
setResult(null)
setError('')
if (fileRef.current) fileRef.current.value = ''
}
return (
<div className="max-w-3xl">
<div className="mb-6">
<h2 className="text-2xl font-bold text-white">Maintenance</h2>
<p className="text-slate-400 text-sm mt-1">Export and import backups of your contracts and field definitions</p>
</div>
{/* Success result */}
{result && (
<div className="bg-green-500/10 border border-green-500/30 rounded-xl p-4 mb-6 flex items-start gap-3">
<Check size={20} className="text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-green-300 font-semibold">Restore completed successfully</p>
<p className="text-green-400/80 text-sm mt-1">
{result.fields_restored} field{result.fields_restored !== 1 ? 's' : ''} restored · {result.contracts_imported} contract{result.contracts_imported !== 1 ? 's' : ''} imported · Mode: <span className="capitalize">{result.mode}</span>
</p>
</div>
</div>
)}
{/* Global error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6 flex items-start gap-3">
<AlertTriangle size={20} className="text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Export section */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 mb-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center">
<Download size={20} className="text-indigo-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Export Backup</h3>
<p className="text-slate-400 text-sm">Download a full JSON backup of all contracts and field definitions</p>
</div>
</div>
<div className="bg-slate-800/60 rounded-xl p-4 mb-5 flex items-start gap-3">
<Info size={15} className="text-slate-400 flex-shrink-0 mt-0.5" />
<p className="text-slate-400 text-sm">
The backup file includes all field definitions and every contract record. It does <strong className="text-slate-300">not</strong> include user accounts or passwords.
</p>
</div>
<button
onClick={handleExport}
disabled={exporting}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-5 py-2.5 rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-900/40"
>
{exporting ? <RefreshCw size={16} className="animate-spin" /> : <Download size={16} />}
{exporting ? 'Exporting...' : 'Download Backup'}
</button>
</div>
{/* Import section */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-amber-500/20 rounded-xl flex items-center justify-center">
<Upload size={20} className="text-amber-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Import Backup</h3>
<p className="text-slate-400 text-sm">Restore data from a previously exported backup file</p>
</div>
</div>
{/* Import mode */}
<div className="mb-5">
<label className="block text-sm font-medium text-slate-400 mb-2">Import Mode</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setImportMode('merge')}
className={`text-left p-4 rounded-xl border transition ${
importMode === 'merge'
? 'border-indigo-500 bg-indigo-500/10'
: 'border-slate-700 hover:border-slate-600'
}`}
>
<p className={`text-sm font-semibold mb-1 ${importMode === 'merge' ? 'text-indigo-400' : 'text-white'}`}>
Merge
</p>
<p className="text-xs text-slate-400">Add backup records to existing data. Existing field definitions are updated if the key matches. Contracts are always appended.</p>
</button>
<button
type="button"
onClick={() => setImportMode('replace')}
className={`text-left p-4 rounded-xl border transition ${
importMode === 'replace'
? 'border-red-500 bg-red-500/10'
: 'border-slate-700 hover:border-slate-600'
}`}
>
<p className={`text-sm font-semibold mb-1 ${importMode === 'replace' ? 'text-red-400' : 'text-white'}`}>
Replace
</p>
<p className="text-xs text-slate-400">Delete all existing contracts and fields before importing. This cannot be undone.</p>
</button>
</div>
</div>
{/* Replace warning */}
{importMode === 'replace' && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-5 flex items-start gap-3">
<ShieldAlert size={18} className="text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-400 text-sm">
<strong>Warning:</strong> Replace mode will permanently delete ALL existing contracts and field definitions before importing. This action cannot be undone.
</p>
</div>
)}
{/* File upload */}
<div className="mb-5">
<label className="block text-sm font-medium text-slate-400 mb-2">Backup File</label>
<div
onClick={() => fileRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition ${
selectedFile
? 'border-indigo-500/50 bg-indigo-500/5'
: 'border-slate-700 hover:border-slate-600 hover:bg-slate-800/30'
}`}
>
<input
type="file"
accept=".json,application/json"
ref={fileRef}
onChange={handleFileChange}
className="hidden"
/>
{selectedFile ? (
<div className="flex items-center justify-center gap-3">
<FileJson size={24} className="text-indigo-400" />
<div className="text-left">
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
<p className="text-slate-400 text-xs">{(selectedFile.size / 1024).toFixed(1)} KB</p>
</div>
</div>
) : (
<div>
<FileJson size={32} className="text-slate-600 mx-auto mb-2" />
<p className="text-slate-400 text-sm">Click to select a <strong className="text-slate-300">.json</strong> backup file</p>
</div>
)}
</div>
{previewError && (
<div className="mt-3 bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 flex items-center gap-2">
<AlertTriangle size={14} className="text-red-400" />
<p className="text-red-400 text-sm">{previewError}</p>
</div>
)}
</div>
{/* Preview */}
{previewData && (
<div className="mb-5 bg-slate-800/60 rounded-xl p-4 border border-slate-700">
<p className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Database size={14} className="text-slate-400" />Backup Preview
</p>
<div className="grid grid-cols-2 gap-3 text-sm">
{[
{ label: 'Exported', value: previewData.exported_at ? new Date(previewData.exported_at).toLocaleString() : 'Unknown' },
{ label: 'Exported by', value: previewData.exported_by || 'Unknown' },
{ label: 'Fields', value: `${previewData.fields.length} field definition${previewData.fields.length !== 1 ? 's' : ''}` },
{ label: 'Contracts', value: `${previewData.contracts.length} record${previewData.contracts.length !== 1 ? 's' : ''}` },
].map(({ label, value }) => (
<div key={label} className="bg-slate-800 rounded-lg px-3 py-2.5">
<p className="text-xs text-slate-500 mb-0.5">{label}</p>
<p className="text-slate-200 text-sm font-medium">{value}</p>
</div>
))}
</div>
{previewData.fields.length > 0 && (
<div className="mt-3">
<p className="text-xs text-slate-500 mb-1.5">Fields in backup:</p>
<div className="flex flex-wrap gap-1.5">
{previewData.fields.map(f => (
<span key={f.field_key} className="text-xs bg-slate-700 text-slate-300 px-2.5 py-1 rounded-full">
{f.label}
</span>
))}
</div>
</div>
)}
</div>
)}
<div className="flex gap-3">
{selectedFile && (
<button
type="button"
onClick={clearFile}
className="px-4 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm font-medium"
>
Clear
</button>
)}
<button
onClick={handleImport}
disabled={importing || !previewData}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition disabled:opacity-50 ${
importMode === 'replace'
? 'bg-red-600/80 hover:bg-red-600 text-white'
: 'bg-amber-600/80 hover:bg-amber-600 text-white'
}`}
>
{importing ? <RefreshCw size={16} className="animate-spin" /> : <Upload size={16} />}
{importing ? 'Importing...' : importMode === 'replace' ? 'Replace & Import' : 'Merge & Import'}
</button>
</div>
</div>
</div>
)
}
+214
View File
@@ -0,0 +1,214 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../AuthContext.jsx'
import api from '../api.js'
import { Shield, KeyRound, Check, User, Eye, EyeOff, QrCode, ShieldOff } from 'lucide-react'
export default function Profile() {
const { user, updateUser } = useAuth()
const navigate = useNavigate()
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
const [showPw, setShowPw] = useState(false)
const [pwLoading, setPwLoading] = useState(false)
const [pwError, setPwError] = useState('')
const [pwSuccess, setPwSuccess] = useState('')
const [mfaSetup, setMfaSetup] = useState(null) // { qr, secret }
const [mfaCode, setMfaCode] = useState('')
const [mfaLoading, setMfaLoading] = useState(false)
const [mfaError, setMfaError] = useState('')
const [mfaSuccess, setMfaSuccess] = useState('')
const handlePwChange = async (e) => {
e.preventDefault()
setPwError(''); setPwSuccess('')
if (pwForm.new_password !== pwForm.confirm) return setPwError('Passwords do not match')
if (pwForm.new_password.length < 6) return setPwError('Password must be at least 6 characters')
setPwLoading(true)
try {
await api.post('/auth/change-password', { current_password: pwForm.current_password, new_password: pwForm.new_password })
setPwSuccess('Password changed successfully!')
setPwForm({ current_password: '', new_password: '', confirm: '' })
} catch (err) {
setPwError(err.response?.data?.error || 'Failed to change password')
} finally {
setPwLoading(false)
}
}
const startMfaSetup = async () => {
setMfaError(''); setMfaSuccess('')
setMfaLoading(true)
try {
const { data } = await api.post('/auth/mfa/setup')
setMfaSetup(data)
} catch {
setMfaError('Failed to start MFA setup')
} finally {
setMfaLoading(false)
}
}
const verifyMfa = async () => {
setMfaError('')
setMfaLoading(true)
try {
await api.post('/auth/mfa/verify', { code: mfaCode })
updateUser({ mfa_enabled: true })
setMfaSetup(null)
setMfaCode('')
setMfaSuccess('MFA enabled successfully!')
} catch (err) {
setMfaError(err.response?.data?.error || 'Invalid code')
} finally {
setMfaLoading(false)
}
}
const disableMfa = async () => {
setMfaError(''); setMfaSuccess('')
setMfaLoading(true)
try {
await api.post('/auth/mfa/disable')
updateUser({ mfa_enabled: false })
setMfaSuccess('MFA disabled.')
} catch {
setMfaError('Failed to disable MFA')
} finally {
setMfaLoading(false)
}
}
return (
<div className="max-w-2xl">
<h2 className="text-2xl font-bold text-white mb-6">Profile</h2>
{/* User Info */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 mb-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-indigo-600/20 rounded-2xl flex items-center justify-center">
<User size={32} className="text-indigo-400" />
</div>
<div>
<h3 className="text-xl font-bold text-white">{user?.username}</h3>
<span className={`text-sm px-3 py-1 rounded-full font-medium ${user?.role === 'admin' ? 'bg-amber-500/20 text-amber-400' : 'bg-indigo-500/20 text-indigo-400'}`}>
{user?.role}
</span>
</div>
</div>
</div>
{/* Change Password */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 mb-6">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center">
<KeyRound size={20} className="text-indigo-400" />
</div>
<h3 className="text-lg font-bold text-white">Change Password</h3>
</div>
{pwSuccess && <div className="bg-green-500/10 border border-green-500/30 text-green-400 text-sm rounded-xl px-4 py-3 mb-4 flex items-center gap-2"><Check size={14} />{pwSuccess}</div>}
{pwError && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3 mb-4">{pwError}</div>}
<form onSubmit={handlePwChange} className="space-y-4">
{['current_password', 'new_password', 'confirm'].map((field) => (
<div key={field}>
<label className="block text-sm font-medium text-slate-400 mb-1.5">
{field === 'current_password' ? 'Current Password' : field === 'new_password' ? 'New Password' : 'Confirm New Password'}
</label>
<div className="relative">
<input
type={showPw ? 'text' : 'password'}
value={pwForm[field]}
onChange={e => setPwForm({ ...pwForm, [field]: e.target.value })}
required
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 pr-12 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
placeholder="••••••••"
/>
{field === 'current_password' && (
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
)}
</div>
</div>
))}
<button type="submit" disabled={pwLoading} className="w-full py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold disabled:opacity-50 transition">
{pwLoading ? 'Saving...' : 'Change Password'}
</button>
</form>
</div>
{/* MFA */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 bg-emerald-500/20 rounded-xl flex items-center justify-center">
<Shield size={20} className="text-emerald-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Two-Factor Authentication</h3>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${user?.mfa_enabled ? 'bg-green-500/20 text-green-400' : 'bg-slate-700 text-slate-400'}`}>
{user?.mfa_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
{mfaSuccess && <div className="bg-green-500/10 border border-green-500/30 text-green-400 text-sm rounded-xl px-4 py-3 mb-4 flex items-center gap-2"><Check size={14} />{mfaSuccess}</div>}
{mfaError && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3 mb-4">{mfaError}</div>}
{!user?.mfa_enabled && !mfaSetup && (
<div>
<p className="text-slate-400 text-sm mb-4">Protect your account with Google Authenticator or any TOTP-compatible app.</p>
<button onClick={startMfaSetup} disabled={mfaLoading} className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-600/30 px-4 py-2.5 rounded-xl text-sm font-semibold transition disabled:opacity-50">
<QrCode size={16} />{mfaLoading ? 'Loading...' : 'Set Up MFA'}
</button>
</div>
)}
{mfaSetup && (
<div className="space-y-4">
<div className="text-sm text-slate-400 space-y-2">
<p>1. Install <strong className="text-white">Google Authenticator</strong> or any TOTP app.</p>
<p>2. Scan this QR code with the app:</p>
</div>
<div className="flex justify-center">
<img src={mfaSetup.qr} alt="QR Code" className="w-48 h-48 rounded-xl bg-white p-2" />
</div>
<div className="bg-slate-800 rounded-xl p-3 text-center">
<p className="text-xs text-slate-400 mb-1">Manual entry code:</p>
<code className="text-xs text-indigo-400 font-mono break-all">{mfaSetup.secret}</code>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">3. Enter the 6-digit code to confirm</label>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={mfaCode}
onChange={e => setMfaCode(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white text-center text-2xl tracking-widest focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition"
placeholder="000000"
/>
</div>
<div className="flex gap-3">
<button onClick={() => { setMfaSetup(null); setMfaCode('') }} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm">Cancel</button>
<button onClick={verifyMfa} disabled={mfaLoading || mfaCode.length !== 6} className="flex-1 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold disabled:opacity-50 transition text-sm">
{mfaLoading ? 'Verifying...' : 'Enable MFA'}
</button>
</div>
</div>
)}
{user?.mfa_enabled && !mfaSetup && (
<div>
<p className="text-slate-400 text-sm mb-4">MFA is active. Your account is protected with two-factor authentication.</p>
<button onClick={disableMfa} disabled={mfaLoading} className="flex items-center gap-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/30 px-4 py-2.5 rounded-xl text-sm font-semibold transition disabled:opacity-50">
<ShieldOff size={16} />{mfaLoading ? 'Disabling...' : 'Disable MFA'}
</button>
</div>
)}
</div>
</div>
)
}
+1
View File
@@ -0,0 +1 @@
a