Skip to content

Commit

Permalink
Merge pull request #196 from FalkorDB/users
Browse files Browse the repository at this point in the history
fix #191 Add Users view
  • Loading branch information
Anchel123 authored May 2, 2024
2 parents d235e41 + 8e433ed commit 4e8bace
Show file tree
Hide file tree
Showing 14 changed files with 405 additions and 12 deletions.
2 changes: 1 addition & 1 deletion app/api/graph/[graph]/[node]/route.ts
Original file line number Diff line number Diff line change
@@ -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 } }) {
Expand Down
2 changes: 1 addition & 1 deletion app/api/graph/[graph]/route.ts
Original file line number Diff line number Diff line change
@@ -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 } }) {
Expand Down
2 changes: 1 addition & 1 deletion app/api/graph/route.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion app/api/monitor/route.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions app/api/user/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface User{
username: string
password?: string
role?: string
}
107 changes: 107 additions & 0 deletions app/api/user/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
import { getClient } from "@/app/api/auth/[...nextauth]/options";

const ROLE = new Map<string, string[]>(
[
["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 })
}
}
10 changes: 3 additions & 7 deletions app/graph/mainQuery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand Down
80 changes: 80 additions & 0 deletions app/users/AddUser.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>()
const passwordInputRef = createRef<HTMLInputElement>()
const [selectedValue, setSelectedValue] = useState("Read-Only")

const addUser = async (event: React.FormEvent<HTMLFormElement>) => {
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 (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Add new user</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={addUser} >
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogDescription>
Pick a new username to add a new user.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="username" className="text-right">
Username
</Label>
<Input id="username" ref={usernameInputRef} className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="password" className="text-right">
Password
</Label>
<Input id="password" type="password" ref={passwordInputRef} className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="role" className="text-right">
Role
</Label>
<Combobox type="Role" options={["Admin", "Read-Write", "Read-Only"]} selectedValue={selectedValue} setSelectedValue={setSelectedValue} />
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="submit" variant="secondary">Add</Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
54 changes: 54 additions & 0 deletions app/users/DeleteUser.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AlertDialog>
<AlertDialogTrigger disabled={selected.length === 0} asChild>
<Button variant="outline">Delete selected users</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected users.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={deleteSelected} >Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
83 changes: 83 additions & 0 deletions app/users/page.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean[]>([])

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 (
<div className="w-full h-full p-2 flex flex-col space-y-4">
<h1 className="text-2xl font-bold">Users</h1>
<div className="space-y-2 list-disc border rounded-lg border-gray-300 p-2">
<div className="flex flex-row space-x-2">
<AddUser />
<DeleteUser users={users} selectedRows={selectedRows} />
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[32px]">
<Checkbox id="select-all" onCheckedChange={(checked: CheckedState) => setSelectedRows(new Array(users.length).fill(checked))} />
</TableHead>
<TableHead>Username</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user: User, index: number) => (
<TableRow key={user.username}>
<TableCell>
<Checkbox
checked={selectedRows[index]}
onCheckedChange={(checked) => onSelect(checked, index)} />
</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.role}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
Loading

0 comments on commit 4e8bace

Please sign in to comment.