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:
Eric Jungbauer
2026-03-20 01:45:13 +00:00
parent 0bae347e65
commit 05aad75272
56 changed files with 11815 additions and 0 deletions
+14
View File
@@ -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>
+29
View File
@@ -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"
}
}
+4
View File
@@ -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

+58
View File
@@ -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;
}
+16
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}
+250
View File
@@ -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>
);
}
+180
View File
@@ -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();
+38
View File
@@ -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;
}
+22
View File
@@ -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"]
}
+23
View File
@@ -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,
},
},
},
});