Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
230 lines
6.9 KiB
TypeScript
230 lines
6.9 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import useSWR from "swr";
|
||
import AppShell from "@/components/AppShell";
|
||
import {
|
||
ConfirmDialog,
|
||
EmptyState,
|
||
FormError,
|
||
LoadError,
|
||
Modal,
|
||
PageHeader,
|
||
SkeletonRows,
|
||
StatusBadge,
|
||
} from "@/components/ui";
|
||
import { useToast } from "@/components/toast";
|
||
import { api, fetcher } from "@/lib/api";
|
||
|
||
type User = {
|
||
id: number;
|
||
email: string;
|
||
full_name: string | null;
|
||
role: string;
|
||
is_active: boolean;
|
||
};
|
||
|
||
const ROLES = ["readonly", "support", "admin"];
|
||
|
||
export default function UsersPage() {
|
||
const { data: users, error: usersError, isLoading, mutate } = useSWR<User[]>(
|
||
"/api/users",
|
||
fetcher
|
||
);
|
||
const toast = useToast();
|
||
const [open, setOpen] = useState(false);
|
||
const [form, setForm] = useState({ email: "", password: "", full_name: "", role: "readonly" });
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [creating, setCreating] = useState(false);
|
||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||
const [deleting, setDeleting] = useState(false);
|
||
|
||
const create = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError(null);
|
||
setCreating(true);
|
||
try {
|
||
await api("/api/users", { method: "POST", body: JSON.stringify(form) });
|
||
setOpen(false);
|
||
setForm({ email: "", password: "", full_name: "", role: "readonly" });
|
||
toast.success("User created");
|
||
mutate();
|
||
} catch (err: any) {
|
||
setError(err.message);
|
||
toast.error(err.message || "Could not create user");
|
||
} finally {
|
||
setCreating(false);
|
||
}
|
||
};
|
||
|
||
const update = async (id: number, patch: Partial<User>) => {
|
||
try {
|
||
await api(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) });
|
||
toast.success("User updated");
|
||
mutate();
|
||
} catch (err: any) {
|
||
toast.error(err.message || "Update failed");
|
||
}
|
||
};
|
||
|
||
const remove = async () => {
|
||
if (deleteId == null) return;
|
||
setDeleting(true);
|
||
try {
|
||
await api(`/api/users/${deleteId}`, { method: "DELETE" });
|
||
toast.success("User deleted");
|
||
mutate();
|
||
} catch (err: any) {
|
||
toast.error(err.message || "Delete failed");
|
||
} finally {
|
||
setDeleting(false);
|
||
setDeleteId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<AppShell>
|
||
<PageHeader
|
||
title="Users"
|
||
subtitle="Manage operators and their roles"
|
||
action={
|
||
<button className="btn-primary" onClick={() => setOpen(true)}>
|
||
+ New user
|
||
</button>
|
||
}
|
||
/>
|
||
{isLoading && !users ? (
|
||
<SkeletonRows rows={4} />
|
||
) : usersError ? (
|
||
<LoadError message="Couldn’t load users." onRetry={() => mutate()} />
|
||
) : (users || []).length === 0 ? (
|
||
<EmptyState title="No users yet" description="Create the first operator account." />
|
||
) : (
|
||
<div className="card overflow-x-auto p-0">
|
||
<table className="w-full min-w-[640px]">
|
||
<thead>
|
||
<tr>
|
||
<th className="th">Email</th>
|
||
<th className="th">Name</th>
|
||
<th className="th">Role</th>
|
||
<th className="th">Active</th>
|
||
<th className="th"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(users || []).map((u) => (
|
||
<tr key={u.id}>
|
||
<td className="td">{u.email}</td>
|
||
<td className="td">{u.full_name || "—"}</td>
|
||
<td className="td">
|
||
<select
|
||
aria-label={`Role for ${u.email}`}
|
||
className="input max-w-[140px]"
|
||
value={u.role}
|
||
onChange={(e) => update(u.id, { role: e.target.value as any })}
|
||
>
|
||
{ROLES.map((r) => (
|
||
<option key={r} value={r}>
|
||
{r}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</td>
|
||
<td className="td">
|
||
<button
|
||
aria-label={`Toggle active for ${u.email}`}
|
||
aria-pressed={u.is_active}
|
||
onClick={() => update(u.id, { is_active: !u.is_active })}
|
||
>
|
||
<StatusBadge status={u.is_active ? "loaded" : "stopped"} />
|
||
</button>
|
||
</td>
|
||
<td className="td text-right">
|
||
<button className="btn-danger btn-sm" onClick={() => setDeleteId(u.id)}>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
<Modal open={open} onClose={() => setOpen(false)} title="Create user">
|
||
<form onSubmit={create} className="space-y-4">
|
||
<div>
|
||
<label htmlFor="user-email" className="label">
|
||
Email
|
||
</label>
|
||
<input
|
||
id="user-email"
|
||
className="input"
|
||
type="email"
|
||
value={form.email}
|
||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="user-name" className="label">
|
||
Full name
|
||
</label>
|
||
<input
|
||
id="user-name"
|
||
className="input"
|
||
value={form.full_name}
|
||
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="user-password" className="label">
|
||
Password
|
||
</label>
|
||
<input
|
||
id="user-password"
|
||
className="input"
|
||
type="password"
|
||
value={form.password}
|
||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="user-role" className="label">
|
||
Role
|
||
</label>
|
||
<select
|
||
id="user-role"
|
||
className="input"
|
||
value={form.role}
|
||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||
>
|
||
{ROLES.map((r) => (
|
||
<option key={r} value={r}>
|
||
{r}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<FormError message={error} />
|
||
<button className="btn-primary w-full" disabled={creating}>
|
||
{creating ? "Creating…" : "Create"}
|
||
</button>
|
||
</form>
|
||
</Modal>
|
||
|
||
<ConfirmDialog
|
||
open={deleteId != null}
|
||
title="Delete user?"
|
||
message="This permanently removes the user account."
|
||
confirmLabel="Delete"
|
||
destructive
|
||
busy={deleting}
|
||
onConfirm={remove}
|
||
onCancel={() => setDeleteId(null)}
|
||
/>
|
||
</AppShell>
|
||
);
|
||
}
|