diff --git a/app/api/graph/[graph]/[node]/route.ts b/app/api/graph/[graph]/[node]/route.ts index 34f849ae..31e8e94b 100644 --- a/app/api/graph/[graph]/[node]/route.ts +++ b/app/api/graph/[graph]/[node]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getClient } from "../../../auth/[...nextauth]/options"; +import { getClient } from "@/app/api/auth/[...nextauth]/options"; // eslint-disable-next-line import/prefer-default-export export async function GET(request: NextRequest, { params }: { params: { graph: string, node: string } }) { diff --git a/app/api/graph/[graph]/route.ts b/app/api/graph/[graph]/route.ts index a4d31dbd..12cfb9a0 100644 --- a/app/api/graph/[graph]/route.ts +++ b/app/api/graph/[graph]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getClient } from "../../auth/[...nextauth]/options"; +import { getClient } from "@/app/api/auth/[...nextauth]/options"; // eslint-disable-next-line import/prefer-default-export export async function DELETE(request: NextRequest, { params }: { params: { graph: string } }) { diff --git a/app/api/graph/route.ts b/app/api/graph/route.ts index 0aacdb08..2910ebf5 100644 --- a/app/api/graph/route.ts +++ b/app/api/graph/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getClient } from "../auth/[...nextauth]/options"; +import { getClient } from "@/app/api/auth/[...nextauth]/options"; // eslint-disable-next-line import/prefer-default-export export async function GET(request: NextRequest) { diff --git a/app/api/monitor/route.ts b/app/api/monitor/route.ts index 5e08ea76..71fd3d93 100644 --- a/app/api/monitor/route.ts +++ b/app/api/monitor/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getClient } from "../auth/[...nextauth]/options"; +import { getClient } from "@/app/api/auth/[...nextauth]/options"; const fileds = [ "used_memory", diff --git a/app/api/user/model.ts b/app/api/user/model.ts new file mode 100644 index 00000000..87760ee3 --- /dev/null +++ b/app/api/user/model.ts @@ -0,0 +1,5 @@ +export interface User{ + username: string + password?: string + role?: string +} \ No newline at end of file diff --git a/app/api/user/route.ts b/app/api/user/route.ts new file mode 100644 index 00000000..7ba0a735 --- /dev/null +++ b/app/api/user/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getClient } from "@/app/api/auth/[...nextauth]/options"; + +const ROLE = new Map( + [ + ["Admin", ["on", "~*", "&*", "+@all"]], + ["Read-Write", ["on", "~*", "resetchannels", "-@all", "+graph.query", "+graph.profile", "+graph.explain", "+graph.list", "+ping"]], + ["Read-Only", ["on", "~*", "resetchannels", "-@all", "+graph.ro_query", "+graph.explain", "+graph.list", "+ping"]] + ] +) + +interface User { + username: string + password: string + role: string +} + +// eslint-disable-next-line import/prefer-default-export +export async function GET() { + + const client = await getClient() + if (client instanceof NextResponse) { + return client + } + try { + const list = await client.connection.aclList() + const users = list + .map((userACL: string) => userACL.split(" ")) + .filter((userDetails: string[]) => userDetails.length > 1 && userDetails[0] === "user") + .map((userDetails: string[]) => { + // find key in ROLE that has a value equals to userDetails by comaring each array + const role = Array + .from(ROLE.entries()) + .find(([, value]) => { + const reversed = value.slice(1).reverse() + return reversed.every((val, index) => userDetails[userDetails.length - 1 - index] === val) + }) + + return { + username: userDetails[1], + role: role ? role[0] : "Unknown" + } + }) + + return NextResponse.json({ result: { users } }, { status: 200 }) + } catch (err: unknown) { + return NextResponse.json({ message: (err as Error).message }, { status: 400 }) + } +} + +// eslint-disable-next-line import/prefer-default-export +export async function POST(req: NextRequest) { + + const client = await getClient() + if (client instanceof NextResponse) { + return client + } + + const { username, password, role } = await req.json() as User + + const roleValue = ROLE.get(role) + try { + if (!username || !password || !roleValue) throw (new Error("Missing parameters")) + + try { + const user = await client.connection.aclGetUser(username) + + if (user) { + return NextResponse.json({ message: `User ${username} already exists` }, { status: 409 }) + } + } catch (err: unknown) { + // Just a workaround for https://github.com/redis/node-redis/issues/2745 + } + + await client.connection.aclSetUser(username, roleValue.concat(`>${password}`)) + return NextResponse.json( + { message: "User created" }, + { + status: 201, + headers: { + location: `/api/db/user/${username}` + } + } + ) + } catch (err: unknown) { + return NextResponse.json({ message: (err as Error).message }, { status: 400 }) + } +} + +// eslint-disable-next-line import/prefer-default-export +export async function DELETE(req: NextRequest) { + + const client = await getClient() + if (client instanceof NextResponse) { + return client + } + + const { users } = await req.json() + try { + await Promise.all(users.map(async (user: User) => { + await client.connection.aclDelUser(user.username) + })) + return NextResponse.json({ message: "Users deleted" }, { status: 200 }) + } catch (err: unknown) { + return NextResponse.json({ message: (err as Error).message }, { status: 400 }) + } +} \ No newline at end of file diff --git a/app/graph/mainQuery.tsx b/app/graph/mainQuery.tsx index 823c5460..5cb6a17b 100644 --- a/app/graph/mainQuery.tsx +++ b/app/graph/mainQuery.tsx @@ -41,24 +41,20 @@ export default function MainQuery({ onSubmit, onDelete, className = "" }: { const height = getHeight(); useEffect(() => { - fetch('/api/graph', { + securedFetch('/api/graph', { method: 'GET', headers: { 'Content-Type': 'application/json' } }).then((result) => { - if (result.status < 300) { + if (result.ok) { return result.json() } - toast({ - title: "Error", - description: result.text(), - }) return { result: [] } }).then((result) => { setGraphs(result.result.graphs ?? []) }) - }, [toast]) + }, []) const handelDelete = (name: string) => { setGraphName('') diff --git a/app/users/AddUser.tsx b/app/users/AddUser.tsx new file mode 100644 index 00000000..7687afd4 --- /dev/null +++ b/app/users/AddUser.tsx @@ -0,0 +1,80 @@ +import { Button } from "@/components/ui/button"; +import { createRef, useState } from "react" +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/use-toast"; +import { securedFetch } from "@/lib/utils"; +import Combobox from "../components/combobox"; + + +export default function AddUser() { + const usernameInputRef = createRef() + const passwordInputRef = createRef() + const [selectedValue, setSelectedValue] = useState("Read-Only") + + const addUser = async (event: React.FormEvent) => { + event.preventDefault(); + const username = usernameInputRef.current?.value; + const password = passwordInputRef.current?.value; + if (!username || !password) { + return; + } + const response = await securedFetch('/api/user/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ username, password, role: selectedValue}) + }) + + if (response.ok) { + toast({ + title: "Success", + description: "User created", + }); + } + }; + return ( + + + + + +
+ + Add User + + Pick a new username to add a new user. + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/app/users/DeleteUser.tsx b/app/users/DeleteUser.tsx new file mode 100644 index 00000000..697b4dfd --- /dev/null +++ b/app/users/DeleteUser.tsx @@ -0,0 +1,54 @@ +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/use-toast"; +import { User } from "@/app/api/user/model"; +import { securedFetch } from "@/lib/utils"; + +interface DeleteUserProps { + users: User[] + selectedRows: boolean[] +} + +export default function DeleteUser({ users, selectedRows} : DeleteUserProps) { + + const selected = users.filter((_: User, index: number) => selectedRows[index]) + + const deleteSelected = async () => { + if (selected.length === 0) return + + const response = await securedFetch(`/api/user/`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ users: selected }) + }) + + if (response.ok) { + toast({ + title: "Success", + description: "Users deleted", + }) + } + } + + return ( + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the selected users. + + + + Cancel + Continue + + + + ); +} \ No newline at end of file diff --git a/app/users/page.tsx b/app/users/page.tsx new file mode 100644 index 00000000..8230dd01 --- /dev/null +++ b/app/users/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import React from "react"; +import useSWR from "swr"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { CheckedState } from "@radix-ui/react-checkbox"; +import { User } from "@/app/api/user/model"; +import { securedFetch } from "@/lib/utils"; +import DeleteUser from "./DeleteUser"; +import AddUser from "./AddUser"; + +const fetcher = async (url: string) => { + const response = await securedFetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + + if (response.ok) { + const data = await response.json() + return data.result + } + return { users: [] } +} + +// Shows the details of a current database connection +export default function Page() { + + const { data } = useSWR(`/api/user/`, fetcher, { refreshInterval: 1000 }) + const [selectedRows, setSelectedRows] = React.useState([]) + + const users: User[] = (data && data.users) || [] + if (users.length !== selectedRows.length) { + setSelectedRows(new Array(users.length).fill(false)) + } + + // Handle the select/unselect a checkbox + const onSelect = (checked: CheckedState, index: number) => { + setSelectedRows((prev) => { + const next = [...prev] + next[index] = checked === true + return next + }) + } + + return ( +
+

Users

+
+
+ + +
+ + + + + setSelectedRows(new Array(users.length).fill(checked))} /> + + Username + Role + + + + {users.map((user: User, index: number) => ( + + + onSelect(checked, index)} /> + + {user.username} + {user.role} + + ))} + +
+
+
+ ); +} \ No newline at end of file diff --git a/components/custom/navbar.tsx b/components/custom/navbar.tsx index c16c6d1d..e87e1e41 100644 --- a/components/custom/navbar.tsx +++ b/components/custom/navbar.tsx @@ -1,5 +1,5 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ -import { Activity, Info, LogOut, Moon, Sun, Waypoints } from "lucide-react"; +import { Activity, Info, LogOut, Moon, Sun, Users, Waypoints } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import Link from "next/link"; import { useEffect, useState } from "react"; @@ -27,6 +27,12 @@ const linksUp: LinkDefinition[] = [ href: "/monitor", icon: (), }, + { + name: "Users", + href: "/users", + icon: (), + }, + ] const linksDown: LinkDefinition[] = [ diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 00000000..df61a138 --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/package-lock.json b/package-lock.json index b6e9bef0..4ca2afd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@monaco-editor/react": "^4.6.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", @@ -2574,6 +2575,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.0.4.tgz", + "integrity": "sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", diff --git a/package.json b/package.json index 0347ecde..eb5fafcb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@monaco-editor/react": "^4.6.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2",