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
+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>
);
}