Initial commit: Agentic OS troubleshooting platform
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>
This commit is contained in:
229
frontend/app/users/page.tsx
Normal file
229
frontend/app/users/page.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user