Phase 1: Forward Assist initial build
Multi-tenant AI help desk SaaS for the firearms industry. Full monorepo: API (Express/Prisma), Worker (BullMQ), Frontend (React/Vite/Tailwind). PostgreSQL 16 + pgvector, Redis 7, JWT auth, RLS tenant isolation. Dark Armory theme with tactical branding throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1A1D21" />
|
||||
<title>Forward Assist — AI-Powered Help Desk</title>
|
||||
</head>
|
||||
<body class="bg-[#1A1D21] text-gray-200 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@forward-assist/frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@forward-assist/shared": "*",
|
||||
"axios": "^1.7.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="12" fill="#4A5D23"/>
|
||||
<text x="50" y="68" font-family="Arial Black, sans-serif" font-size="50" font-weight="900" text-anchor="middle" fill="#C3A76B">FA</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1,58 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./contexts/AuthContext";
|
||||
import { AppLayout } from "./components/layout/AppLayout";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { RegisterPage } from "./pages/RegisterPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { TicketDetailPage } from "./pages/TicketDetailPage";
|
||||
import { EmailAccountsPage } from "./pages/EmailAccountsPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { UsersPage } from "./pages/UsersPage";
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-dark-bg">
|
||||
<div className="text-fde text-lg">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
if (isLoading) return null;
|
||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
||||
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="/email-accounts" element={<EmailAccountsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from "react";
|
||||
import { NavLink, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Ticket,
|
||||
Mail,
|
||||
Settings,
|
||||
Users,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", icon: LayoutDashboard, label: "The Armory", exact: true },
|
||||
{ to: "/email-accounts", icon: Mail, label: "Field Strip" },
|
||||
{ to: "/users", icon: Users, label: "Personnel" },
|
||||
{ to: "/settings", icon: Settings, label: "Ops Config" },
|
||||
];
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, tenant, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-dark-bg">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-dark-surface border-r border-dark-border flex flex-col transition-transform duration-200 lg:relative lg:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-5 py-5 border-b border-dark-border">
|
||||
<div className="w-9 h-9 rounded-lg bg-od-green flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-fde" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-fde font-bold text-base leading-tight">Forward Assist</h1>
|
||||
<p className="text-[11px] text-gunmetal-light">Mission Ready Support</p>
|
||||
</div>
|
||||
<button
|
||||
className="ml-auto lg:hidden text-gray-400 hover:text-white"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tenant name */}
|
||||
<div className="px-5 py-3 border-b border-dark-border">
|
||||
<p className="text-xs text-gunmetal-light uppercase tracking-wider">Tenant</p>
|
||||
<p className="text-sm text-gray-200 font-medium truncate">{tenant?.name || "—"}</p>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.exact}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-od-green/20 text-fde"
|
||||
: "text-gray-400 hover:bg-dark-hover hover:text-gray-200"
|
||||
}`
|
||||
}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon className="w-[18px] h-[18px]" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="px-4 py-4 border-t border-dark-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gunmetal flex items-center justify-center text-fde text-xs font-bold">
|
||||
{user?.name?.charAt(0).toUpperCase() || "?"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-200 font-medium truncate">{user?.name}</p>
|
||||
<p className="text-xs text-gunmetal-light truncate">{user?.role}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-1.5 text-gray-500 hover:text-red-400 transition-colors"
|
||||
title="Log out"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Mobile header */}
|
||||
<header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-dark-surface border-b border-dark-border">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-fde font-bold">Forward Assist</h1>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||
import { api } from "../services/api";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
interface TenantInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
plan?: { name: string; slug: string };
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
tenant: TenantInfo | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (tenantName: string, email: string, password: string, name: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
tenant: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
if (!token) {
|
||||
setState({ user: null, tenant: null, isLoading: false, isAuthenticated: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getMe();
|
||||
setState({
|
||||
user: data.user,
|
||||
tenant: data.tenant,
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} catch {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
setState({ user: null, tenant: null, isLoading: false, isAuthenticated: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const data = await api.login(email, password);
|
||||
localStorage.setItem("accessToken", data.accessToken);
|
||||
localStorage.setItem("refreshToken", data.refreshToken);
|
||||
setState({
|
||||
user: data.user,
|
||||
tenant: data.tenant,
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
};
|
||||
|
||||
const register = async (tenantName: string, email: string, password: string, name: string) => {
|
||||
const data = await api.register(tenantName, email, password, name);
|
||||
localStorage.setItem("accessToken", data.accessToken);
|
||||
localStorage.setItem("refreshToken", data.refreshToken);
|
||||
setState({
|
||||
user: data.user,
|
||||
tenant: data.tenant,
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
setState({ user: null, tenant: null, isLoading: false, isAuthenticated: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...state, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error("useAuth must be used within AuthProvider");
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import "./styles/index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { api } from "../services/api";
|
||||
import {
|
||||
Inbox,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Archive,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Crosshair,
|
||||
XCircle,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: React.ElementType }> = {
|
||||
incoming: { label: "Incoming!", color: "text-red-400", bg: "bg-red-500/15", icon: Inbox },
|
||||
in_the_chamber: { label: "In The Chamber", color: "text-amber-400", bg: "bg-amber-500/15", icon: Target },
|
||||
cleared_hot: { label: "Cleared Hot", color: "text-green-400", bg: "bg-green-500/15", icon: CheckCircle },
|
||||
misfire: { label: "Misfire", color: "text-red-500", bg: "bg-red-600/15", icon: XCircle },
|
||||
standby: { label: "Standby", color: "text-gray-400", bg: "bg-gray-500/15", icon: Clock },
|
||||
resolved: { label: "Resolved", color: "text-emerald-400", bg: "bg-emerald-500/15", icon: CheckCircle },
|
||||
archived: { label: "Archived", color: "text-gray-500", bg: "bg-gray-600/15", icon: Archive },
|
||||
};
|
||||
|
||||
const PRIORITY_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
low: { label: "Low", color: "text-gray-400" },
|
||||
medium: { label: "Med", color: "text-blue-400" },
|
||||
high: { label: "High", color: "text-amber-400" },
|
||||
critical: { label: "CRIT", color: "text-red-400" },
|
||||
};
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get("status") || "");
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>(searchParams.get("priority") || "");
|
||||
const [search, setSearch] = useState(searchParams.get("search") || "");
|
||||
const [page, setPage] = useState(parseInt(searchParams.get("page") || "1"));
|
||||
|
||||
const fetchTickets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { page: String(page), pageSize: "25" };
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (priorityFilter) params.priority = priorityFilter;
|
||||
if (search) params.search = search;
|
||||
|
||||
const data = await api.getTickets(params);
|
||||
setTickets(data.data);
|
||||
setTotal(data.total);
|
||||
setTotalPages(data.totalPages);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch tickets:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, statusFilter, priorityFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, [fetchTickets]);
|
||||
|
||||
useEffect(() => {
|
||||
const params: Record<string, string> = {};
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (priorityFilter) params.priority = priorityFilter;
|
||||
if (search) params.search = search;
|
||||
if (page > 1) params.page = String(page);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [statusFilter, priorityFilter, search, page, setSearchParams]);
|
||||
|
||||
const timeAgo = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = (now.getTime() - d.getTime()) / 1000;
|
||||
if (diff < 60) return "just now";
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-fde">The Armory</h1>
|
||||
<p className="text-sm text-gunmetal-light mt-0.5">
|
||||
{total} ticket{total !== 1 ? "s" : ""} total
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchTickets}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-surface border border-dark-border rounded-lg text-sm text-gray-300 hover:bg-dark-hover transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-5">
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<Filter className="w-4 h-4" />
|
||||
Filters:
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-1.5 bg-dark-surface border border-dark-border rounded-lg text-sm text-gray-300 focus:outline-none focus:border-od-green"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
|
||||
<option key={key} value={key}>{cfg.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-1.5 bg-dark-surface border border-dark-border rounded-lg text-sm text-gray-300 focus:outline-none focus:border-od-green"
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search tickets..."
|
||||
className="px-3 py-1.5 bg-dark-surface border border-dark-border rounded-lg text-sm text-gray-300 focus:outline-none focus:border-od-green w-48"
|
||||
/>
|
||||
|
||||
{(statusFilter || priorityFilter || search) && (
|
||||
<button
|
||||
onClick={() => { setStatusFilter(""); setPriorityFilter(""); setSearch(""); setPage(1); }}
|
||||
className="text-sm text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ticket list */}
|
||||
<div className="bg-dark-surface border border-dark-border rounded-xl overflow-hidden">
|
||||
{loading && tickets.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading tickets...
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-500">
|
||||
<Crosshair className="w-10 h-10 mb-3 text-gunmetal" />
|
||||
<p className="text-lg font-medium">No tickets found</p>
|
||||
<p className="text-sm mt-1">Adjust your filters or wait for incoming rounds</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-border text-xs text-gunmetal-light uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3 font-medium">Ticket</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Subject</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Priority</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Customer</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Assignee</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map((ticket) => {
|
||||
const statusCfg = STATUS_CONFIG[ticket.status] || STATUS_CONFIG.incoming;
|
||||
const priorityCfg = PRIORITY_CONFIG[ticket.priority] || PRIORITY_CONFIG.medium;
|
||||
return (
|
||||
<tr
|
||||
key={ticket.id}
|
||||
onClick={() => navigate(`/tickets/${ticket.id}`)}
|
||||
className="border-b border-dark-border/50 hover:bg-dark-hover cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-fde font-mono text-sm font-medium">{ticket.ticketNumber}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-200 truncate max-w-xs">{ticket.subject}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusCfg.bg} ${statusCfg.color}`}>
|
||||
<statusCfg.icon className="w-3 h-3" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs font-medium ${priorityCfg.color}`}>{priorityCfg.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-300 truncate max-w-[150px]">
|
||||
{ticket.customerName || ticket.customerEmail}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-400">{ticket.assignee?.name || "Unassigned"}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-xs text-gunmetal-light">{timeAgo(ticket.lastMessageAt)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-border">
|
||||
<p className="text-sm text-gunmetal-light">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="p-1.5 rounded border border-dark-border text-gray-400 hover:bg-dark-hover disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="p-1.5 rounded border border-dark-border text-gray-400 hover:bg-dark-hover disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { api } from "../services/api";
|
||||
import {
|
||||
Mail,
|
||||
Plus,
|
||||
Settings,
|
||||
Trash2,
|
||||
TestTube2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
export function EmailAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
emailAddress: "",
|
||||
imapHost: "",
|
||||
imapPort: 993,
|
||||
imapUser: "",
|
||||
imapPassword: "",
|
||||
imapTls: true,
|
||||
smtpHost: "",
|
||||
smtpPort: 587,
|
||||
smtpUser: "",
|
||||
smtpPassword: "",
|
||||
smtpTls: true,
|
||||
pollIntervalSeconds: 60,
|
||||
});
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getEmailAccounts();
|
||||
setAccounts(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch email accounts:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
}, []);
|
||||
|
||||
const update = (field: string, value: any) =>
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.createEmailAccount(form);
|
||||
setShowForm(false);
|
||||
setForm({
|
||||
name: "", emailAddress: "",
|
||||
imapHost: "", imapPort: 993, imapUser: "", imapPassword: "", imapTls: true,
|
||||
smtpHost: "", smtpPort: 587, smtpUser: "", smtpPassword: "", smtpTls: true,
|
||||
pollIntervalSeconds: 60,
|
||||
});
|
||||
await fetchAccounts();
|
||||
} catch (err) {
|
||||
console.error("Failed to create email account:", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Deactivate this email account?")) return;
|
||||
try {
|
||||
await api.deleteEmailAccount(id);
|
||||
await fetchAccounts();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete email account:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
try {
|
||||
const result = await api.testEmailAccount(id);
|
||||
alert(`IMAP: ${result.imap.message}\nSMTP: ${result.smtp.message}`);
|
||||
} catch (err) {
|
||||
console.error("Connection test failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-fde">Field Strip</h1>
|
||||
<p className="text-sm text-gunmetal-light mt-0.5">Email account configuration</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Email Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New account form modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-surface border border-dark-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-border">
|
||||
<h2 className="text-lg font-semibold text-gray-200">New Email Account</h2>
|
||||
<button onClick={() => setShowForm(false)} className="text-gray-500 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Display Name</label>
|
||||
<input type="text" value={form.name} onChange={(e) => update("name", e.target.value)} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" placeholder="Support Inbox" />
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Email Address</label>
|
||||
<input type="email" value={form.emailAddress} onChange={(e) => update("emailAddress", e.target.value)} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" placeholder="support@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-dark-border" />
|
||||
<h3 className="text-sm font-medium text-fde">IMAP Settings (Incoming)</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">IMAP Host</label>
|
||||
<input type="text" value={form.imapHost} onChange={(e) => update("imapHost", e.target.value)} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" placeholder="imap.gmail.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">IMAP Port</label>
|
||||
<input type="number" value={form.imapPort} onChange={(e) => update("imapPort", parseInt(e.target.value))} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">IMAP Username</label>
|
||||
<input type="text" value={form.imapUser} onChange={(e) => update("imapUser", e.target.value)} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">IMAP Password</label>
|
||||
<input type="password" value={form.imapPassword} onChange={(e) => update("imapPassword", e.target.value)} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<input type="checkbox" checked={form.imapTls} onChange={(e) => update("imapTls", e.target.checked)} className="rounded border-dark-border" />
|
||||
Use TLS
|
||||
</label>
|
||||
|
||||
<hr className="border-dark-border" />
|
||||
<h3 className="text-sm font-medium text-fde">SMTP Settings (Outgoing)</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">SMTP Host</label>
|
||||
<input type="text" value={form.smtpHost} onChange={(e) => update("smtpHost", e.target.value)} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" placeholder="smtp.gmail.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">SMTP Port</label>
|
||||
<input type="number" value={form.smtpPort} onChange={(e) => update("smtpPort", parseInt(e.target.value))} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">SMTP Username</label>
|
||||
<input type="text" value={form.smtpUser} onChange={(e) => update("smtpUser", e.target.value)} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">SMTP Password</label>
|
||||
<input type="password" value={form.smtpPassword} onChange={(e) => update("smtpPassword", e.target.value)} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<input type="checkbox" checked={form.smtpTls} onChange={(e) => update("smtpTls", e.target.checked)} className="rounded border-dark-border" />
|
||||
Use TLS
|
||||
</label>
|
||||
|
||||
<hr className="border-dark-border" />
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Poll Interval (seconds)</label>
|
||||
<input type="number" value={form.pollIntervalSeconds} onChange={(e) => update("pollIntervalSeconds", parseInt(e.target.value))} min={30} max={3600}
|
||||
className="w-32 px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowForm(false)}
|
||||
className="px-4 py-2 border border-dark-border text-gray-400 rounded-lg text-sm hover:bg-dark-hover">Cancel</button>
|
||||
<button type="submit" disabled={saving}
|
||||
className="px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg disabled:opacity-50">
|
||||
{saving ? "Saving..." : "Deploy Email Account"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accounts list */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading...
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="bg-dark-surface border border-dark-border rounded-xl p-12 text-center">
|
||||
<Mail className="w-12 h-12 text-gunmetal mx-auto mb-3" />
|
||||
<p className="text-lg font-medium text-gray-300">No email accounts configured</p>
|
||||
<p className="text-sm text-gunmetal-light mt-1">Add an email account to start receiving tickets</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accounts.map((acct) => (
|
||||
<div key={acct.id} className="bg-dark-surface border border-dark-border rounded-xl p-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${acct.isActive ? "bg-od-green/20" : "bg-gray-600/20"}`}>
|
||||
<Mail className={`w-5 h-5 ${acct.isActive ? "text-od-green-light" : "text-gray-500"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-200">{acct.name}</h3>
|
||||
<p className="text-xs text-gunmetal-light">{acct.emailAddress}</p>
|
||||
{acct.lastError && (
|
||||
<p className="text-xs text-red-400 mt-0.5 flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" /> {acct.lastError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`flex items-center gap-1 text-xs ${acct.isActive ? "text-green-400" : "text-gray-500"}`}>
|
||||
{acct.isActive ? <CheckCircle className="w-3.5 h-3.5" /> : <XCircle className="w-3.5 h-3.5" />}
|
||||
{acct.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<button onClick={() => handleTest(acct.id)}
|
||||
className="p-2 text-gray-500 hover:text-fde rounded-lg hover:bg-dark-hover" title="Test Connection">
|
||||
<TestTube2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(acct.id)}
|
||||
className="p-2 text-gray-500 hover:text-red-400 rounded-lg hover:bg-dark-hover" title="Deactivate">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { Shield, AlertCircle } from "lucide-react";
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Login failed. Check your credentials.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-bg px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-16 h-16 rounded-2xl bg-od-green flex items-center justify-center mb-4">
|
||||
<Shield className="w-9 h-9 text-fde" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-fde">Forward Assist</h1>
|
||||
<p className="text-sm text-gunmetal-light mt-1">AI-Powered Support, Mission Ready</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-dark-surface border border-dark-border rounded-xl p-8 space-y-5"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-200">Sign In</h2>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 rounded-lg p-3 text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green transition-colors"
|
||||
placeholder="operator@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green transition-colors"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-od-green hover:bg-od-green-light text-white font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
|
||||
>
|
||||
{loading ? "Authenticating..." : "Sign In"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
New unit?{" "}
|
||||
<Link to="/register" className="text-fde hover:text-fde-light transition-colors">
|
||||
Register here
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { Shield, AlertCircle } from "lucide-react";
|
||||
|
||||
export function RegisterPage() {
|
||||
const { register } = useAuth();
|
||||
const [form, setForm] = useState({
|
||||
tenantName: "",
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const update = (field: string, value: string) =>
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(form.tenantName, form.email, form.password, form.name);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Registration failed.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-bg px-4 py-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-16 h-16 rounded-2xl bg-od-green flex items-center justify-center mb-4">
|
||||
<Shield className="w-9 h-9 text-fde" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-fde">Forward Assist</h1>
|
||||
<p className="text-sm text-gunmetal-light mt-1">Set up your command post</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-dark-surface border border-dark-border rounded-xl p-8 space-y-5"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-200">Create Account</h2>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 rounded-lg p-3 text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Company / Organization</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.tenantName}
|
||||
onChange={(e) => update("tenantName", e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||
placeholder="Acme Firearms LLC"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => update("name", e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => update("email", e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||
placeholder="you@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => update("password", e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(e) => update("confirmPassword", e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-od-green hover:bg-od-green-light text-white font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
|
||||
>
|
||||
{loading ? "Deploying..." : "Establish Comms"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className="text-fde hover:text-fde-light transition-colors">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { api } from "../services/api";
|
||||
import { Settings, Save, RefreshCw } from "lucide-react";
|
||||
|
||||
export function SettingsPage() {
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [tenantInfo, setTenantInfo] = useState<any>(null);
|
||||
const [planInfo, setPlanInfo] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getSettings();
|
||||
setSettings(data.settings);
|
||||
setTenantInfo(data.tenant);
|
||||
setPlanInfo(data.plan);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch settings:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
try {
|
||||
const result = await api.updateSettings(settings);
|
||||
setSettings(result.settings);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) {
|
||||
console.error("Failed to save settings:", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const update = (field: string, value: any) => {
|
||||
setSettings((prev: any) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading settings...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-fde">Ops Config</h1>
|
||||
<p className="text-sm text-gunmetal-light mt-0.5">Tenant settings and configuration</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? "Saving..." : saved ? "Saved!" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Plan info */}
|
||||
{planInfo && (
|
||||
<div className="bg-dark-surface border border-dark-border rounded-xl p-5 mb-5">
|
||||
<h2 className="text-sm font-medium text-fde mb-3">Plan Details</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-gunmetal-light">Plan</p>
|
||||
<p className="text-gray-200 font-medium">{planInfo.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gunmetal-light">Max Users</p>
|
||||
<p className="text-gray-200">{planInfo.maxUsers}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gunmetal-light">Max Email Accounts</p>
|
||||
<p className="text-gray-200">{planInfo.maxEmailAccounts}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gunmetal-light">Monthly Tickets</p>
|
||||
<p className="text-gray-200">{planInfo.maxTicketsPerMonth.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General settings */}
|
||||
<div className="bg-dark-surface border border-dark-border rounded-xl p-5 space-y-5">
|
||||
<h2 className="text-sm font-medium text-fde">General Settings</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Timezone</label>
|
||||
<select
|
||||
value={settings?.timezone || "America/New_York"}
|
||||
onChange={(e) => update("timezone", e.target.value)}
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||
>
|
||||
<option value="America/New_York">Eastern Time</option>
|
||||
<option value="America/Chicago">Central Time</option>
|
||||
<option value="America/Denver">Mountain Time</option>
|
||||
<option value="America/Los_Angeles">Pacific Time</option>
|
||||
<option value="America/Phoenix">Arizona</option>
|
||||
<option value="Pacific/Honolulu">Hawaii</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Ticket Prefix</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.ticketPrefix || "FA"}
|
||||
onChange={(e) => update("ticketPrefix", e.target.value.toUpperCase())}
|
||||
maxLength={5}
|
||||
className="w-32 px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green font-mono"
|
||||
/>
|
||||
<p className="text-xs text-gunmetal-light mt-1">Prefix for ticket numbers (e.g., FA-0001)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">AI Tone</label>
|
||||
<select
|
||||
value={settings?.aiTone || "professional"}
|
||||
onChange={(e) => update("aiTone", e.target.value)}
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||
>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="friendly">Friendly</option>
|
||||
<option value="technical">Technical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr className="border-dark-border" />
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.autoReplyEnabled || false}
|
||||
onChange={(e) => update("autoReplyEnabled", e.target.checked)}
|
||||
className="rounded border-dark-border"
|
||||
/>
|
||||
Enable auto-reply for new tickets
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings?.autoReplyEnabled && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Auto-Reply Message</label>
|
||||
<textarea
|
||||
value={settings?.autoReplyMessage || ""}
|
||||
onChange={(e) => update("autoReplyMessage", e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 resize-none focus:outline-none focus:border-od-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { api } from "../services/api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Send,
|
||||
User as UserIcon,
|
||||
Mail,
|
||||
Clock,
|
||||
Tag,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "incoming", label: "Incoming!" },
|
||||
{ value: "in_the_chamber", label: "In The Chamber" },
|
||||
{ value: "cleared_hot", label: "Cleared Hot" },
|
||||
{ value: "misfire", label: "Misfire" },
|
||||
{ value: "standby", label: "Standby" },
|
||||
{ value: "resolved", label: "Resolved" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
];
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "critical", label: "Critical" },
|
||||
];
|
||||
|
||||
export function TicketDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [ticket, setTicket] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const fetchTicket = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getTicket(id);
|
||||
setTicket(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch ticket:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTicket();
|
||||
}, [id]);
|
||||
|
||||
const handleReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyText.trim() || !id) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await api.replyToTicket(id, replyText.trim());
|
||||
setReplyText("");
|
||||
await fetchTicket(); // Refresh to show new message
|
||||
} catch (err) {
|
||||
console.error("Failed to send reply:", err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (status: string) => {
|
||||
if (!id) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
await api.updateTicket(id, { status });
|
||||
setTicket((prev: any) => ({ ...prev, status }));
|
||||
} catch (err) {
|
||||
console.error("Failed to update status:", err);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePriorityChange = async (priority: string) => {
|
||||
if (!id) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
await api.updateTicket(id, { priority });
|
||||
setTicket((prev: any) => ({ ...prev, priority }));
|
||||
} catch (err) {
|
||||
console.error("Failed to update priority:", err);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading ticket...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<p>Ticket not found</p>
|
||||
<button onClick={() => navigate("/")} className="text-fde mt-2 text-sm hover:underline">
|
||||
Back to The Armory
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="p-2 rounded-lg border border-dark-border text-gray-400 hover:bg-dark-hover hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-fde font-mono font-bold text-lg">{ticket.ticketNumber}</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-200 mt-0.5">{ticket.subject}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Messages thread */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Message list */}
|
||||
<div className="space-y-3">
|
||||
{ticket.messages?.map((msg: any) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`bg-dark-surface border rounded-xl p-4 ${
|
||||
msg.direction === "outbound"
|
||||
? "border-od-green/30 ml-8"
|
||||
: "border-dark-border mr-8"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
msg.direction === "outbound"
|
||||
? "bg-od-green/20 text-od-green-light"
|
||||
: "bg-fde/20 text-fde"
|
||||
}`}
|
||||
>
|
||||
{(msg.fromName || msg.fromEmail).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-200">
|
||||
{msg.fromName || msg.fromEmail}
|
||||
</span>
|
||||
<span className="text-xs text-gunmetal-light ml-2">
|
||||
{msg.direction === "outbound" ? "Agent Reply" : "Customer"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gunmetal-light">{formatDate(msg.sentAt || msg.createdAt)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">
|
||||
{msg.bodyText}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reply form */}
|
||||
<form onSubmit={handleReply} className="bg-dark-surface border border-dark-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium text-gray-300 mb-2">Send Reply</p>
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="Type your response..."
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 resize-none focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||
/>
|
||||
<div className="flex justify-end mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!replyText.trim() || sending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{sending ? "Sending..." : "Send It"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Sidebar details */}
|
||||
<div className="space-y-4">
|
||||
{/* Status & Priority */}
|
||||
<div className="bg-dark-surface border border-dark-border rounded-xl p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gunmetal-light uppercase tracking-wider mb-1.5">Status</label>
|
||||
<select
|
||||
value={ticket.status}
|
||||
onChange={(e) => handleStatusChange(e.target.value)}
|
||||
disabled={updating}
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gunmetal-light uppercase tracking-wider mb-1.5">Priority</label>
|
||||
<select
|
||||
value={ticket.priority}
|
||||
onChange={(e) => handlePriorityChange(e.target.value)}
|
||||
disabled={updating}
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="bg-dark-surface border border-dark-border rounded-xl p-4 space-y-3">
|
||||
<h3 className="text-xs text-gunmetal-light uppercase tracking-wider font-medium">Details</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="w-4 h-4 text-gunmetal-light" />
|
||||
<span className="text-gray-400">Customer:</span>
|
||||
<span className="text-gray-200 truncate">{ticket.customerEmail}</span>
|
||||
</div>
|
||||
|
||||
{ticket.customerName && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<UserIcon className="w-4 h-4 text-gunmetal-light" />
|
||||
<span className="text-gray-400">Name:</span>
|
||||
<span className="text-gray-200">{ticket.customerName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<UserIcon className="w-4 h-4 text-gunmetal-light" />
|
||||
<span className="text-gray-400">Assignee:</span>
|
||||
<span className="text-gray-200">{ticket.assignee?.name || "Unassigned"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="w-4 h-4 text-gunmetal-light" />
|
||||
<span className="text-gray-400">Via:</span>
|
||||
<span className="text-gray-200 truncate">{ticket.emailAccount?.emailAddress || "—"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="w-4 h-4 text-gunmetal-light" />
|
||||
<span className="text-gray-400">Created:</span>
|
||||
<span className="text-gray-200">{formatDate(ticket.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Tag className="w-4 h-4 text-gunmetal-light" />
|
||||
<span className="text-gray-400">Messages:</span>
|
||||
<span className="text-gray-200">{ticket.messageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { api } from "../services/api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
Shield,
|
||||
UserCheck,
|
||||
UserX,
|
||||
RefreshCw,
|
||||
X,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
owner: "text-fde bg-fde/15",
|
||||
admin: "text-amber-400 bg-amber-500/15",
|
||||
agent: "text-blue-400 bg-blue-500/15",
|
||||
viewer: "text-gray-400 bg-gray-500/15",
|
||||
};
|
||||
|
||||
export function UsersPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({ email: "", name: "", role: "agent" });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getUsers();
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch users:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setInviting(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.invite(inviteForm.email, inviteForm.name, inviteForm.role);
|
||||
setShowInvite(false);
|
||||
setInviteForm({ email: "", name: "", role: "agent" });
|
||||
alert(`User invited! Temporary password: ${result.temporaryPassword}`);
|
||||
await fetchUsers();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to invite user");
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (userId: string, currentlyActive: boolean) => {
|
||||
try {
|
||||
if (currentlyActive) {
|
||||
await api.deleteUser(userId);
|
||||
} else {
|
||||
await api.updateUser(userId, { isActive: true });
|
||||
}
|
||||
await fetchUsers();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || "Failed to update user");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId: string, role: string) => {
|
||||
try {
|
||||
await api.updateUser(userId, { role });
|
||||
await fetchUsers();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || "Failed to update role");
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmin = currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return "Never";
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-fde">Personnel</h1>
|
||||
<p className="text-sm text-gunmetal-light mt-0.5">Manage your team members</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setShowInvite(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Invite Operator
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite modal */}
|
||||
{showInvite && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-surface border border-dark-border rounded-xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-border">
|
||||
<h2 className="text-lg font-semibold text-gray-200">Invite Operator</h2>
|
||||
<button onClick={() => setShowInvite(false)} className="text-gray-500 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleInvite} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 rounded-lg p-3 text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4" /> {error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Name</label>
|
||||
<input type="text" value={inviteForm.name}
|
||||
onChange={(e) => setInviteForm((p) => ({ ...p, name: e.target.value }))} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||
placeholder="John Doe" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Email</label>
|
||||
<input type="email" value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm((p) => ({ ...p, email: e.target.value }))} required
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||
placeholder="user@company.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Role</label>
|
||||
<select value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm((p) => ({ ...p, role: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green">
|
||||
<option value="admin">Admin</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowInvite(false)}
|
||||
className="px-4 py-2 border border-dark-border text-gray-400 rounded-lg text-sm hover:bg-dark-hover">Cancel</button>
|
||||
<button type="submit" disabled={inviting}
|
||||
className="px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg disabled:opacity-50">
|
||||
{inviting ? "Sending..." : "Send Invite"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User list */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-dark-surface border border-dark-border rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-border text-xs text-gunmetal-light uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3 font-medium">User</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Role</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Last Login</th>
|
||||
{isAdmin && <th className="text-right px-4 py-3 font-medium">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-dark-border/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gunmetal flex items-center justify-center text-fde text-xs font-bold">
|
||||
{u.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-200 font-medium">{u.name}</p>
|
||||
<p className="text-xs text-gunmetal-light">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{isAdmin && u.role !== "owner" && u.id !== currentUser?.id ? (
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={(e) => handleRoleChange(u.id, e.target.value)}
|
||||
className="px-2 py-1 bg-dark-bg border border-dark-border rounded text-xs text-gray-200 focus:outline-none focus:border-od-green"
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${ROLE_COLORS[u.role] || ""}`}>
|
||||
<Shield className="w-3 h-3" />
|
||||
{u.role.charAt(0).toUpperCase() + u.role.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`flex items-center gap-1 text-xs ${u.isActive ? "text-green-400" : "text-gray-500"}`}>
|
||||
{u.isActive ? <UserCheck className="w-3.5 h-3.5" /> : <UserX className="w-3.5 h-3.5" />}
|
||||
{u.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-gunmetal-light">{formatDate(u.lastLoginAt)}</span>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
{u.role !== "owner" && u.id !== currentUser?.id && (
|
||||
<button
|
||||
onClick={() => handleToggleActive(u.id, u.isActive)}
|
||||
className={`text-xs px-3 py-1 rounded border ${
|
||||
u.isActive
|
||||
? "border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
: "border-green-500/30 text-green-400 hover:bg-green-500/10"
|
||||
}`}
|
||||
>
|
||||
{u.isActive ? "Deactivate" : "Activate"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import axios, { AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private refreshing: Promise<string> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
// Request interceptor: attach access token
|
||||
this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor: handle 401 with token refresh
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const newToken = await this.refreshToken();
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return this.client(originalRequest);
|
||||
} catch {
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
window.location.href = "/login";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<string> {
|
||||
// Deduplicate concurrent refresh requests
|
||||
if (this.refreshing) return this.refreshing;
|
||||
|
||||
this.refreshing = (async () => {
|
||||
const refreshToken = localStorage.getItem("refreshToken");
|
||||
if (!refreshToken) throw new Error("No refresh token");
|
||||
|
||||
const { data } = await axios.post(`${API_BASE}/auth/refresh`, { refreshToken });
|
||||
localStorage.setItem("accessToken", data.accessToken);
|
||||
localStorage.setItem("refreshToken", data.refreshToken);
|
||||
this.refreshing = null;
|
||||
return data.accessToken;
|
||||
})();
|
||||
|
||||
return this.refreshing;
|
||||
}
|
||||
|
||||
// Auth
|
||||
async login(email: string, password: string) {
|
||||
const { data } = await this.client.post("/auth/login", { email, password });
|
||||
return data;
|
||||
}
|
||||
|
||||
async register(tenantName: string, email: string, password: string, name: string) {
|
||||
const { data } = await this.client.post("/auth/register", { tenantName, email, password, name });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getMe() {
|
||||
const { data } = await this.client.get("/auth/me");
|
||||
return data;
|
||||
}
|
||||
|
||||
async invite(email: string, name: string, role: string) {
|
||||
const { data } = await this.client.post("/auth/invite", { email, name, role });
|
||||
return data;
|
||||
}
|
||||
|
||||
// Tickets
|
||||
async getTickets(params?: Record<string, string>) {
|
||||
const { data } = await this.client.get("/tickets", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTicket(id: string) {
|
||||
const { data } = await this.client.get(`/tickets/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createTicket(body: Record<string, unknown>) {
|
||||
const { data } = await this.client.post("/tickets", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateTicket(id: string, body: Record<string, unknown>) {
|
||||
const { data } = await this.client.patch(`/tickets/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
async replyToTicket(id: string, body: string, bodyHtml?: string) {
|
||||
const { data } = await this.client.post(`/tickets/${id}/reply`, { body, bodyHtml });
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTicketMessages(id: string) {
|
||||
const { data } = await this.client.get(`/tickets/${id}/messages`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Email Accounts
|
||||
async getEmailAccounts() {
|
||||
const { data } = await this.client.get("/email-accounts");
|
||||
return data;
|
||||
}
|
||||
|
||||
async getEmailAccount(id: string) {
|
||||
const { data } = await this.client.get(`/email-accounts/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async createEmailAccount(body: Record<string, unknown>) {
|
||||
const { data } = await this.client.post("/email-accounts", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateEmailAccount(id: string, body: Record<string, unknown>) {
|
||||
const { data } = await this.client.patch(`/email-accounts/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteEmailAccount(id: string) {
|
||||
const { data } = await this.client.delete(`/email-accounts/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async testEmailAccount(id: string) {
|
||||
const { data } = await this.client.post(`/email-accounts/${id}/test`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Settings
|
||||
async getSettings() {
|
||||
const { data } = await this.client.get("/settings");
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateSettings(body: Record<string, unknown>) {
|
||||
const { data } = await this.client.patch("/settings", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Users
|
||||
async getUsers() {
|
||||
const { data } = await this.client.get("/users");
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateUser(id: string, body: Record<string, unknown>) {
|
||||
const { data } = await this.client.patch(`/users/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
const { data } = await this.client.delete(`/users/${id}`);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
@@ -0,0 +1,38 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-od-green: #4A5D23;
|
||||
--color-od-green-light: #5A7A2D;
|
||||
--color-od-green-dark: #3A4A1B;
|
||||
--color-fde: #C3A76B;
|
||||
--color-fde-light: #D4BE8E;
|
||||
--color-fde-dark: #A68D52;
|
||||
--color-gunmetal: #51555C;
|
||||
--color-gunmetal-light: #6B7280;
|
||||
--color-dark-bg: #1A1D21;
|
||||
--color-dark-surface: #22262B;
|
||||
--color-dark-border: #2D3239;
|
||||
--color-dark-hover: #323840;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1A1D21;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2D3239;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #51555C;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: rgba(74, 93, 35, 0.4);
|
||||
color: #C3A76B;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
allowedHosts: ["tickets.jfamily.io", ".jfamily.io"],
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user