1111
This commit is contained in:
6
frontend/src/app/App.tsx
Normal file
6
frontend/src/app/App.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { router } from "./router";
|
||||
|
||||
export function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
8
frontend/src/app/providers.tsx
Normal file
8
frontend/src/app/providers.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function AppProviders({ children }: PropsWithChildren) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
33
frontend/src/app/router.tsx
Normal file
33
frontend/src/app/router.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { UsersPage } from "../pages/UsersPage";
|
||||
import { RolesPage } from "../pages/RolesPage";
|
||||
import { AuditPage } from "../pages/AuditPage";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <DashboardPage />
|
||||
},
|
||||
{
|
||||
path: "/users",
|
||||
element: <UsersPage />
|
||||
},
|
||||
{
|
||||
path: "/roles",
|
||||
element: <RolesPage />
|
||||
},
|
||||
{
|
||||
path: "/audit",
|
||||
element: <AuditPage />
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <Navigate to="/" replace />
|
||||
}
|
||||
]);
|
||||
297
frontend/src/app/styles.css
Normal file
297
frontend/src/app/styles.css
Normal file
@@ -0,0 +1,297 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Inter", sans-serif;
|
||||
--bg: #f4f7fb;
|
||||
--panel: rgba(255, 255, 255, 0.96);
|
||||
--panel-solid: #ffffff;
|
||||
--sidebar: #0f172a;
|
||||
--sidebar-muted: #94a3b8;
|
||||
--line: #dbe3ef;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--primary: #2563eb;
|
||||
--primary-soft: #dbeafe;
|
||||
--success: #16a34a;
|
||||
--danger: #dc2626;
|
||||
--shadow: 0 30px 70px rgba(15, 23, 42, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(37, 99, 235, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
|
||||
color: white;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: rgba(37, 99, 235, 0.18);
|
||||
color: #bfdbfe;
|
||||
border: 1px solid rgba(191, 219, 254, 0.24);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
color: var(--sidebar-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-item,
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-decoration: none;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
transition: 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-item:hover,
|
||||
.nav-link:hover,
|
||||
.sidebar-item.active,
|
||||
.nav-link.active {
|
||||
background: rgba(30, 41, 59, 0.9);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.main-area {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.panel,
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(219, 227, 239, 0.9);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: #e2e8f0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: #fee2e2;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 420px);
|
||||
padding: 36px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
22
frontend/src/features/auth/use-session.ts
Normal file
22
frontend/src/features/auth/use-session.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../../shared/api/client";
|
||||
import type { SessionUser } from "../../shared/types";
|
||||
|
||||
export function useSession() {
|
||||
return useQuery({
|
||||
queryKey: ["session"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<{ user: SessionUser }>("/auth/session");
|
||||
return response.data.user;
|
||||
},
|
||||
retry: false
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
return async () => {
|
||||
await api.post("/auth/logout");
|
||||
queryClient.clear();
|
||||
};
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./app/App";
|
||||
import { AppProviders } from "./app/providers";
|
||||
import "./app/styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<AppProviders>
|
||||
<App />
|
||||
</AppProviders>
|
||||
);
|
||||
50
frontend/src/pages/AuditPage.tsx
Normal file
50
frontend/src/pages/AuditPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../shared/api/client";
|
||||
import { useLogout, useSession } from "../features/auth/use-session";
|
||||
import { Sidebar } from "../widgets/Sidebar";
|
||||
import { Topbar } from "../widgets/Topbar";
|
||||
import { AuditTable } from "../widgets/AuditTable";
|
||||
|
||||
export function AuditPage() {
|
||||
const navigate = useNavigate();
|
||||
const session = useSession();
|
||||
const logout = useLogout();
|
||||
const auditQuery = useQuery({
|
||||
queryKey: ["audit"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<Array<Record<string, unknown>>>("/audit?page=1&limit=50");
|
||||
return response.data;
|
||||
},
|
||||
enabled: session.isSuccess
|
||||
});
|
||||
|
||||
if (session.isError) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (session.isLoading || !session.data) {
|
||||
return <main className="login-page"><section className="panel login-card">Loading audit log...</section></main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar groups={[]} currentTable={null} onSelectTable={() => undefined} />
|
||||
<main className="main-area">
|
||||
<Topbar
|
||||
user={session.data}
|
||||
onOpenSql={() => navigate("/")}
|
||||
onLogout={async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}}
|
||||
/>
|
||||
<section className="panel">
|
||||
<h1 className="section-title">Audit Trail</h1>
|
||||
<p className="muted">Authentication events, SQL executions, and administrative changes are collected here.</p>
|
||||
<AuditTable rows={auditQuery.data ?? []} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
frontend/src/pages/DashboardPage.tsx
Normal file
184
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../shared/api/client";
|
||||
import type { SidebarGroup, TableColumn, TableIndex } from "../shared/types";
|
||||
import { useLogout, useSession } from "../features/auth/use-session";
|
||||
import { Sidebar } from "../widgets/Sidebar";
|
||||
import { Topbar } from "../widgets/Topbar";
|
||||
import { DataGrid } from "../widgets/DataGrid";
|
||||
import { SchemaEditor } from "../widgets/SchemaEditor";
|
||||
import { SqlConsole } from "../widgets/SqlConsole";
|
||||
import { LogViewer } from "../widgets/LogViewer";
|
||||
|
||||
type RecordResponse = {
|
||||
data: Array<Record<string, unknown>>;
|
||||
meta: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const session = useSession();
|
||||
const logout = useLogout();
|
||||
const [currentTable, setCurrentTable] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"data" | "structure" | "sql" | "logs">("data");
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ["sidebar"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<SidebarGroup[]>("/navigation/sidebar");
|
||||
return response.data;
|
||||
},
|
||||
enabled: session.isSuccess
|
||||
});
|
||||
|
||||
const columnsQuery = useQuery({
|
||||
queryKey: ["structure", currentTable],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<TableColumn[]>(`/tables/${currentTable}/structure`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: Boolean(currentTable)
|
||||
});
|
||||
|
||||
const indexesQuery = useQuery({
|
||||
queryKey: ["indexes", currentTable],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<TableIndex[]>(`/tables/${currentTable}/indexes`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: Boolean(currentTable)
|
||||
});
|
||||
|
||||
const relationsQuery = useQuery({
|
||||
queryKey: ["relations", currentTable],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<Array<Record<string, unknown>>>(`/tables/${currentTable}/relations`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: Boolean(currentTable)
|
||||
});
|
||||
|
||||
const recordsQuery = useQuery({
|
||||
queryKey: ["records", currentTable],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<Array<Record<string, unknown>>>(
|
||||
`/tables/${currentTable}/records?page=1&limit=25&search=&filters={}`
|
||||
);
|
||||
return {
|
||||
data: response.data,
|
||||
meta: response.meta as RecordResponse["meta"]
|
||||
};
|
||||
},
|
||||
enabled: Boolean(currentTable)
|
||||
});
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ["logs"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<string[]>("/logs?q=&severity=&page=1&limit=50");
|
||||
return response.data;
|
||||
},
|
||||
enabled: session.isSuccess
|
||||
});
|
||||
|
||||
const tableCount = useMemo(
|
||||
() => groupsQuery.data?.reduce((sum, group) => sum + group.tables.length, 0) ?? 0,
|
||||
[groupsQuery.data]
|
||||
);
|
||||
|
||||
if (session.isError) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (session.isLoading || !session.data) {
|
||||
return <main className="login-page"><section className="panel login-card">Loading session...</section></main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar groups={groupsQuery.data ?? []} currentTable={currentTable} onSelectTable={setCurrentTable} />
|
||||
<main className="main-area">
|
||||
<Topbar
|
||||
user={session.data}
|
||||
onOpenSql={() => setActiveTab("sql")}
|
||||
onLogout={async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="panel">
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<h1 className="section-title">{currentTable ?? "Choose a table from the sidebar"}</h1>
|
||||
<div className="muted">
|
||||
{tableCount} tables across managed groups. Current role: <span className="code">{session.data.roleSlug}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-pill">Control DB + target DB mediated through backend API</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="tabs">
|
||||
{[
|
||||
["data", "Data"],
|
||||
["structure", "Structure"],
|
||||
["sql", "SQL"],
|
||||
["logs", "Logs"]
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
className={`tab ${activeTab === value ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(value as typeof activeTab)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="content-grid">
|
||||
<div className="stack">
|
||||
{activeTab === "data" ? <DataGrid rows={recordsQuery.data?.data ?? []} /> : null}
|
||||
{activeTab === "structure" ? (
|
||||
<SchemaEditor
|
||||
columns={columnsQuery.data ?? []}
|
||||
indexes={indexesQuery.data ?? []}
|
||||
relations={relationsQuery.data ?? []}
|
||||
/>
|
||||
) : null}
|
||||
<SqlConsole visible={activeTab === "sql"} />
|
||||
{activeTab === "logs" ? <LogViewer logs={logsQuery.data ?? []} /> : null}
|
||||
</div>
|
||||
|
||||
<aside className="stack">
|
||||
<section className="panel">
|
||||
<h3 className="section-title">Current Table Summary</h3>
|
||||
<div className="stack muted">
|
||||
<div>Columns: {columnsQuery.data?.length ?? 0}</div>
|
||||
<div>Indexes: {indexesQuery.data?.length ?? 0}</div>
|
||||
<div>Relations: {relationsQuery.data?.length ?? 0}</div>
|
||||
<div>Loaded rows: {recordsQuery.data?.data.length ?? 0}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h3 className="section-title">Operational Notes</h3>
|
||||
<div className="stack muted">
|
||||
<div>Backend enforces RBAC and table/group scoping.</div>
|
||||
<div>SQL console is mediated by a guard layer and fully audited.</div>
|
||||
<div>Schema and record mutations are intended to flow only through API policies.</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/pages/LoginPage.tsx
Normal file
54
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { Database } from "lucide-react";
|
||||
import { api } from "../shared/api/client";
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("root");
|
||||
const [password, setPassword] = useState("root12345");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="panel login-card stack">
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div className="brand" style={{ justifyContent: "center", color: "var(--text)" }}>
|
||||
<Database size={36} />
|
||||
<div>PostgreSQL SensoLab</div>
|
||||
</div>
|
||||
<p className="muted">Secure PostgreSQL administration with RBAC, audit, and SQL guardrails.</p>
|
||||
</div>
|
||||
<label className="stack">
|
||||
<span>Username</span>
|
||||
<input className="input" value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||
</label>
|
||||
<label className="stack">
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error ? <div className="muted" style={{ color: "var(--danger)" }}>{error}</div> : null}
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setError("");
|
||||
await api.post("/auth/login", { username, password });
|
||||
navigate("/");
|
||||
} catch (requestError) {
|
||||
setError(requestError instanceof Error ? requestError.message : "Login failed");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
69
frontend/src/pages/RolesPage.tsx
Normal file
69
frontend/src/pages/RolesPage.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../shared/api/client";
|
||||
import { useLogout, useSession } from "../features/auth/use-session";
|
||||
import { Sidebar } from "../widgets/Sidebar";
|
||||
import { Topbar } from "../widgets/Topbar";
|
||||
|
||||
export function RolesPage() {
|
||||
const navigate = useNavigate();
|
||||
const session = useSession();
|
||||
const logout = useLogout();
|
||||
const rolesQuery = useQuery({
|
||||
queryKey: ["roles"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<Array<Record<string, unknown>>>("/roles");
|
||||
return response.data;
|
||||
},
|
||||
enabled: session.isSuccess
|
||||
});
|
||||
|
||||
if (session.isError) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (session.isLoading || !session.data) {
|
||||
return <main className="login-page"><section className="panel login-card">Loading roles...</section></main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar groups={[]} currentTable={null} onSelectTable={() => undefined} />
|
||||
<main className="main-area">
|
||||
<Topbar
|
||||
user={session.data}
|
||||
onOpenSql={() => navigate("/")}
|
||||
onLogout={async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}}
|
||||
/>
|
||||
<section className="panel">
|
||||
<h1 className="section-title">Roles and Permissions</h1>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Description</th>
|
||||
<th>Permissions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(rolesQuery.data ?? []).map((row) => (
|
||||
<tr key={String(row.id)}>
|
||||
<td>{String(row.name ?? "")}</td>
|
||||
<td>{String(row.slug ?? "")}</td>
|
||||
<td>{String(row.description ?? "")}</td>
|
||||
<td className="code">{JSON.stringify(row.permissions ?? [])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
frontend/src/pages/UsersPage.tsx
Normal file
69
frontend/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../shared/api/client";
|
||||
import { useLogout, useSession } from "../features/auth/use-session";
|
||||
import { Sidebar } from "../widgets/Sidebar";
|
||||
import { Topbar } from "../widgets/Topbar";
|
||||
|
||||
export function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const session = useSession();
|
||||
const logout = useLogout();
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<Array<Record<string, unknown>>>("/users");
|
||||
return response.data;
|
||||
},
|
||||
enabled: session.isSuccess
|
||||
});
|
||||
|
||||
if (session.isError) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (session.isLoading || !session.data) {
|
||||
return <main className="login-page"><section className="panel login-card">Loading users...</section></main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar groups={[]} currentTable={null} onSelectTable={() => undefined} />
|
||||
<main className="main-area">
|
||||
<Topbar
|
||||
user={session.data}
|
||||
onOpenSql={() => navigate("/")}
|
||||
onLogout={async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}}
|
||||
/>
|
||||
<section className="panel">
|
||||
<h1 className="section-title">User Management</h1>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Active</th>
|
||||
<th>Locked</th>
|
||||
<th>Roles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(usersQuery.data ?? []).map((row) => (
|
||||
<tr key={String(row.id)}>
|
||||
<td>{String(row.username ?? "")}</td>
|
||||
<td>{String(row.is_active ?? "")}</td>
|
||||
<td>{String(row.is_locked ?? "")}</td>
|
||||
<td className="code">{JSON.stringify(row.roles ?? [])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/shared/api/client.ts
Normal file
55
frontend/src/shared/api/client.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type ApiSuccess<T> = {
|
||||
success: true;
|
||||
data: T;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ApiError = {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = ApiSuccess<T> | ApiError;
|
||||
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<ApiSuccess<T>> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
},
|
||||
...init
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as ApiResponse<T>;
|
||||
if (!response.ok || !payload.success) {
|
||||
throw new Error(payload.success ? "Request failed" : payload.error.message);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
}),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: "PUT",
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
}),
|
||||
delete: <T>(path: string) =>
|
||||
request<T>(path, {
|
||||
method: "DELETE"
|
||||
})
|
||||
};
|
||||
41
frontend/src/shared/types.ts
Normal file
41
frontend/src/shared/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type PermissionGrant = {
|
||||
resource: string;
|
||||
action: string;
|
||||
scopeType: string;
|
||||
scopeValue: string | null;
|
||||
};
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
roleSlug: string;
|
||||
isRoot: boolean;
|
||||
permissions: PermissionGrant[];
|
||||
};
|
||||
|
||||
export type SidebarGroup = {
|
||||
slug: string;
|
||||
name: string;
|
||||
tables: Array<{
|
||||
name: string;
|
||||
schema: string;
|
||||
group_slug: string;
|
||||
display_name: string;
|
||||
estimated_rows: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TableColumn = {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
default_value: string | null;
|
||||
is_primary: boolean;
|
||||
};
|
||||
|
||||
export type TableIndex = {
|
||||
name: string;
|
||||
definition: string;
|
||||
unique: boolean;
|
||||
type: string;
|
||||
};
|
||||
32
frontend/src/widgets/AuditTable.tsx
Normal file
32
frontend/src/widgets/AuditTable.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
type AuditTableProps = {
|
||||
rows: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export function AuditTable({ rows }: AuditTableProps) {
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Action</th>
|
||||
<th>Resource</th>
|
||||
<th>Status</th>
|
||||
<th>Actor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={String(row.id)}>
|
||||
<td>{String(row.created_at ?? "")}</td>
|
||||
<td>{String(row.action ?? "")}</td>
|
||||
<td>{String(row.resource_type ?? "")}</td>
|
||||
<td>{String(row.status ?? "")}</td>
|
||||
<td>{String(row.actor_user_id ?? "")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/widgets/DataGrid.tsx
Normal file
44
frontend/src/widgets/DataGrid.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
type DataGridProps = {
|
||||
rows: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export function DataGrid({ rows }: DataGridProps) {
|
||||
if (rows.length === 0) {
|
||||
return <div className="empty-state">No rows found for the current query.</div>;
|
||||
}
|
||||
|
||||
const columns = Object.keys(rows[0]);
|
||||
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column}>{column}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{columns.map((column) => (
|
||||
<td key={column}>{renderValue(row[column])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderValue(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return <span className="muted">null</span>;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return <code className="code">{JSON.stringify(value)}</code>;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
27
frontend/src/widgets/LogViewer.tsx
Normal file
27
frontend/src/widgets/LogViewer.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
type LogViewerProps = {
|
||||
logs: string[];
|
||||
};
|
||||
|
||||
export function LogViewer({ logs }: LogViewerProps) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<h3 className="section-title">PostgreSQL Logs</h3>
|
||||
<div className="table-wrap" style={{ maxHeight: 360 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Entry</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((line, index) => (
|
||||
<tr key={index}>
|
||||
<td className="code">{line}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
93
frontend/src/widgets/SchemaEditor.tsx
Normal file
93
frontend/src/widgets/SchemaEditor.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { TableColumn, TableIndex } from "../shared/types";
|
||||
|
||||
type SchemaEditorProps = {
|
||||
columns: TableColumn[];
|
||||
indexes: TableIndex[];
|
||||
relations: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export function SchemaEditor({ columns, indexes, relations }: SchemaEditorProps) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="panel">
|
||||
<h3 className="section-title">Columns</h3>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Nullable</th>
|
||||
<th>Default</th>
|
||||
<th>PK</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((column) => (
|
||||
<tr key={column.name}>
|
||||
<td>{column.name}</td>
|
||||
<td>{column.type}</td>
|
||||
<td>{column.nullable ? "Yes" : "No"}</td>
|
||||
<td>{column.default_value ?? "-"}</td>
|
||||
<td>{column.is_primary ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h3 className="section-title">Indexes</h3>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Unique</th>
|
||||
<th>Definition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{indexes.map((index) => (
|
||||
<tr key={index.name}>
|
||||
<td>{index.name}</td>
|
||||
<td>{index.type}</td>
|
||||
<td>{index.unique ? "Yes" : "No"}</td>
|
||||
<td className="code">{index.definition}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h3 className="section-title">Relations</h3>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Constraint</th>
|
||||
<th>Column</th>
|
||||
<th>Foreign Table</th>
|
||||
<th>Foreign Column</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{relations.map((relation, index) => (
|
||||
<tr key={index}>
|
||||
<td>{String(relation.constraint_name ?? "")}</td>
|
||||
<td>{String(relation.column_name ?? "")}</td>
|
||||
<td>{String(relation.foreign_table_name ?? "")}</td>
|
||||
<td>{String(relation.foreign_column_name ?? "")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/widgets/Sidebar.tsx
Normal file
65
frontend/src/widgets/Sidebar.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Database, FileClock, Shield, Users } from "lucide-react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import type { SidebarGroup } from "../shared/types";
|
||||
|
||||
type SidebarProps = {
|
||||
groups: SidebarGroup[];
|
||||
currentTable: string | null;
|
||||
onSelectTable: (tableName: string) => void;
|
||||
};
|
||||
|
||||
export function Sidebar({ groups, currentTable, onSelectTable }: SidebarProps) {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<Database size={28} />
|
||||
<div>
|
||||
<div>PostgreSQL SensoLab</div>
|
||||
<div className="badge">Production control plane</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-group">
|
||||
<div className="sidebar-title">Navigation</div>
|
||||
<NavLink className="nav-link" to="/">
|
||||
<span>Dashboard</span>
|
||||
<Database size={16} />
|
||||
</NavLink>
|
||||
<NavLink className="nav-link" to="/users">
|
||||
<span>Users</span>
|
||||
<Users size={16} />
|
||||
</NavLink>
|
||||
<NavLink className="nav-link" to="/roles">
|
||||
<span>Roles</span>
|
||||
<Shield size={16} />
|
||||
</NavLink>
|
||||
<NavLink className="nav-link" to="/audit">
|
||||
<span>Audit</span>
|
||||
<FileClock size={16} />
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-group" style={{ flex: 1, overflow: "auto" }}>
|
||||
<div className="sidebar-title">Table Groups</div>
|
||||
{groups.map((group) => (
|
||||
<div key={group.slug} className="sidebar-group">
|
||||
<div className="muted" style={{ fontSize: 13, fontWeight: 700 }}>
|
||||
{group.name}
|
||||
</div>
|
||||
{group.tables.map((table) => (
|
||||
<button
|
||||
key={table.name}
|
||||
className={`sidebar-item ${currentTable === table.name ? "active" : ""}`}
|
||||
onClick={() => onSelectTable(table.name)}
|
||||
type="button"
|
||||
>
|
||||
<span>{table.display_name}</span>
|
||||
<span className="badge">{table.estimated_rows}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
87
frontend/src/widgets/SqlConsole.tsx
Normal file
87
frontend/src/widgets/SqlConsole.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../shared/api/client";
|
||||
|
||||
type SqlConsoleProps = {
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export function SqlConsole({ visible }: SqlConsoleProps) {
|
||||
const [sql, setSql] = useState("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';");
|
||||
const [result, setResult] = useState<Record<string, unknown>[]>([]);
|
||||
const [meta, setMeta] = useState<{
|
||||
rowCount: number;
|
||||
durationMs: number;
|
||||
statementType: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<h3 className="section-title">SQL Console</h3>
|
||||
<div className="stack">
|
||||
<textarea className="textarea" value={sql} onChange={(event) => setSql(event.target.value)} />
|
||||
<div className="row">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setError("");
|
||||
const response = await api.post<{
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
durationMs: number;
|
||||
statementType: string;
|
||||
fields: Array<{ name: string }>;
|
||||
notice?: string;
|
||||
}>("/sql/execute", { sql });
|
||||
setResult(response.data.rows);
|
||||
setMeta({
|
||||
rowCount: response.data.rowCount,
|
||||
durationMs: response.data.durationMs,
|
||||
statementType: response.data.statementType
|
||||
});
|
||||
} catch (requestError) {
|
||||
setError(requestError instanceof Error ? requestError.message : "SQL execution failed");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Execute
|
||||
</button>
|
||||
</div>
|
||||
{error ? <div className="muted" style={{ color: "var(--danger)" }}>{error}</div> : null}
|
||||
{meta ? (
|
||||
<div className="muted">
|
||||
{meta.statementType} · {meta.rowCount} rows · {meta.durationMs} ms
|
||||
</div>
|
||||
) : null}
|
||||
{result.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{Object.keys(result[0]).map((key) => (
|
||||
<th key={key}>{key}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{Object.keys(result[0]).map((key) => (
|
||||
<td key={key}>{String(row[key] ?? "")}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
33
frontend/src/widgets/Topbar.tsx
Normal file
33
frontend/src/widgets/Topbar.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { LogOut, ShieldCheck, Terminal } from "lucide-react";
|
||||
import type { SessionUser } from "../shared/types";
|
||||
|
||||
type TopbarProps = {
|
||||
user: SessionUser;
|
||||
onOpenSql: () => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export function Topbar({ user, onOpenSql, onLogout }: TopbarProps) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<div className="status-pill">
|
||||
<ShieldCheck size={16} />
|
||||
<span>
|
||||
{user.username} · {user.roleSlug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="button secondary" type="button" onClick={onOpenSql}>
|
||||
<Terminal size={16} style={{ marginRight: 8 }} />
|
||||
SQL Console
|
||||
</button>
|
||||
<button className="button danger" type="button" onClick={onLogout}>
|
||||
<LogOut size={16} style={{ marginRight: 8 }} />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user