v_1
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mobile Contract Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests to the backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Serve React SPA
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "mobile-mgmt-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"axios": "^1.13.5",
|
||||
"lucide-react": "^0.575.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"vite": "^7.3.1",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
a
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user