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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user