From 43514acc92969ea7c695f373eacbf7db3bb786fc Mon Sep 17 00:00:00 2001 From: mbecker20 Date: Sun, 21 Apr 2024 17:37:31 -0700 Subject: [PATCH] manage user groups --- client/core/ts/src/responses.ts | 2 +- frontend/src/components/keys/table.tsx | 53 +++++ frontend/src/components/topbar.tsx | 13 +- .../src/components/users/service-api-key.tsx | 2 +- frontend/src/components/users/table.tsx | 62 ++++++ frontend/src/components/util.tsx | 6 +- frontend/src/pages/keys.tsx | 42 +--- frontend/src/pages/user-group.tsx | 177 +++++++++++++++++ frontend/src/pages/user.tsx | 105 ++++++++++ frontend/src/pages/users.tsx | 183 ++---------------- frontend/src/router.tsx | 4 +- 11 files changed, 438 insertions(+), 211 deletions(-) create mode 100644 frontend/src/components/keys/table.tsx create mode 100644 frontend/src/components/users/table.tsx create mode 100644 frontend/src/pages/user-group.tsx create mode 100644 frontend/src/pages/user.tsx diff --git a/client/core/ts/src/responses.ts b/client/core/ts/src/responses.ts index 2b49b7eee..2ad782b40 100644 --- a/client/core/ts/src/responses.ts +++ b/client/core/ts/src/responses.ts @@ -16,7 +16,7 @@ export type ReadResponses = { ListUsers: Types.ListUsersResponse; GetUsername: Types.GetUsernameResponse; ListApiKeys: Types.ListApiKeysResponse; - ListApiKeysForServiceUser: Types.ListApiKeysForServiceUser; + ListApiKeysForServiceUser: Types.ListApiKeysForServiceUserResponse; ListPermissions: Types.ListPermissionsResponse; GetPermissionLevel: Types.GetPermissionLevelResponse; ListUserTargetPermissions: Types.ListUserTargetPermissionsResponse; diff --git a/frontend/src/components/keys/table.tsx b/frontend/src/components/keys/table.tsx new file mode 100644 index 000000000..49ecc8dd2 --- /dev/null +++ b/frontend/src/components/keys/table.tsx @@ -0,0 +1,53 @@ +import { CopyButton } from "@components/util"; +import { Types } from "@monitor/client"; +import { DataTable } from "@ui/data-table"; +import { Input } from "@ui/input"; +import { ReactNode } from "react"; + +const ONE_DAY_MS = 1000 * 60 * 60 * 24; + +export const KeysTable = ({ + keys, + DeleteKey, +}: { + keys: Types.ApiKey[]; + DeleteKey: (params: { api_key: string }) => ReactNode; +}) => { + return ( + { + return ( +
+ + +
+ ); + }, + }, + { + header: "Expires", + accessorFn: ({ expires }) => + expires + ? "In " + + ((expires - Date.now()) / ONE_DAY_MS).toFixed() + + " Days" + : "Never", + }, + { + header: "Delete", + cell: ({ row }) => , + }, + ]} + /> + ); +}; diff --git a/frontend/src/components/topbar.tsx b/frontend/src/components/topbar.tsx index ff4711b56..eff6cf095 100644 --- a/frontend/src/components/topbar.tsx +++ b/frontend/src/components/topbar.tsx @@ -6,12 +6,12 @@ import { Boxes, FileQuestion, FolderTree, - Group, Home, Key, SearchX, Tag, UserCircle2, + Users, } from "lucide-react"; import { DropdownMenu, @@ -87,7 +87,7 @@ const PrimaryDropdown = () => { : location.pathname === "/alerts" ? [, "Alerts"] : location.pathname.split("/")[1] === "user-groups" - ? [, "User Groups"] + ? [, "User Groups"] : location.pathname === "/users" || location.pathname.split("/")[1] === "users" ? [, "Users"] @@ -309,7 +309,7 @@ const UserGroupDropdown = ({ group_id }: { group_id: string | undefined }) => { variant="ghost" className="flex justify-start items-center gap-2 w-48 px-3" > - + {selected ? selected.name : "All User Groups"} @@ -351,7 +351,7 @@ const UserGroupDropdown = ({ group_id }: { group_id: string | undefined }) => { variant="link" className="flex gap-2 items-center p-0" > - + {group.name} @@ -379,7 +379,10 @@ const UsersDropdown = ({ user_id }: { user_id: string | undefined }) => { return ( - diff --git a/frontend/src/components/users/service-api-key.tsx b/frontend/src/components/users/service-api-key.tsx index 2ae7d4fd3..647d0bb39 100644 --- a/frontend/src/components/users/service-api-key.tsx +++ b/frontend/src/components/users/service-api-key.tsx @@ -57,7 +57,7 @@ export const CreateKeyForServiceUser = ({ user_id }: { user_id: string }) => { return ( - diff --git a/frontend/src/components/users/table.tsx b/frontend/src/components/users/table.tsx new file mode 100644 index 000000000..bca31b08f --- /dev/null +++ b/frontend/src/components/users/table.tsx @@ -0,0 +1,62 @@ +import { text_color_class_by_intention } from "@lib/color"; +import { Types } from "@monitor/client"; +import { DataTable } from "@ui/data-table"; +import { useNavigate } from "react-router-dom"; +import { ColumnDef } from "@tanstack/react-table"; +import { MinusCircle } from "lucide-react"; +import { ConfirmButton } from "@components/util"; + +export const UserTable = ({ + users, + onUserRemove, +}: { + users: Types.User[]; + onUserRemove?: (user_id: string) => void; +}) => { + const nav = useNavigate(); + const columns: ColumnDef[] = [ + { header: "Username", accessorKey: "username" }, + { header: "Type", accessorKey: "config.type" }, + { + header: "Level", + accessorFn: (user) => (user.admin ? "Admin" : "User"), + }, + { + header: "Enabled", + cell: ({ row }) => { + const enabledClass = row.original.enabled + ? text_color_class_by_intention("Good") + : text_color_class_by_intention("Critical"); + return ( +
+ {row.original.enabled ? "Enabled" : "Disabled"} +
+ ); + }, + }, + ]; + if (onUserRemove) { + columns.push({ + header: "Remove", + cell: ({ row }) => ( + } + onClick={(e) => { + e.stopPropagation(); + onUserRemove(row.original._id?.$oid!); + }} + /> + ), + }); + } + return ( + nav(`/users/${user._id!.$oid}`)} + /> + ); +}; diff --git a/frontend/src/components/util.tsx b/frontend/src/components/util.tsx index ee266a408..d78bb69a8 100644 --- a/frontend/src/components/util.tsx +++ b/frontend/src/components/util.tsx @@ -220,10 +220,14 @@ export const ConfirmButton = ({ onClick={ confirmed ? (e) => { + e.stopPropagation(); onClick && onClick(e); set(false); } - : () => set(true) + : (e) => { + e.stopPropagation(); + set(true); + } } onBlur={() => set(false)} loading={loading} diff --git a/frontend/src/pages/keys.tsx b/frontend/src/pages/keys.tsx index 4a744ed3c..97e86c9d8 100644 --- a/frontend/src/pages/keys.tsx +++ b/frontend/src/pages/keys.tsx @@ -21,47 +21,17 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@ui/dropdown-menu"; -import { DataTable } from "@ui/data-table"; +import { KeysTable } from "@components/keys/table"; export const Keys = () => { useSetTitle("Api Keys"); const keys = useRead("ListApiKeys", {}).data ?? []; return ( }> - { - return ( -
- - -
- ); - }, - }, - { - header: "Expires", - accessorFn: ({ expires }) => - expires - ? "In " + - ((expires - Date.now()) / ONE_DAY_MS).toFixed() + - " Days" - : "Never", - }, - { - header: "Delete", - cell: ({ row }) => , - }, - ]} +
); @@ -102,7 +72,7 @@ const CreateKey = () => { return ( - diff --git a/frontend/src/pages/user-group.tsx b/frontend/src/pages/user-group.tsx new file mode 100644 index 000000000..157f02f19 --- /dev/null +++ b/frontend/src/pages/user-group.tsx @@ -0,0 +1,177 @@ +import { Page, Section } from "@components/layouts"; +import { PermissionsTable } from "@components/users/permissions-table"; +import { UserTable } from "@components/users/table"; +import { useInvalidate, useRead, useWrite } from "@lib/hooks"; +import { Button } from "@ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@ui/command"; +import { Input } from "@ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; +import { useToast } from "@ui/use-toast"; +import { PlusCircle, Save, SearchX, UserCircle2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; + +export const UserGroupPage = () => { + const { toast } = useToast(); + const inv = useInvalidate(); + const group_id = useParams().id as string; + const group = useRead("ListUserGroups", {}).data?.find( + (group) => group._id?.$oid === group_id + ); + const users = useRead("ListUsers", {}).data; + const [name, setName] = useState(); + useEffect(() => { + if (group) setName(group.name); + }, [group?.name]); + const renameMutate = useWrite("RenameUserGroup", { + onSuccess: () => { + inv(["ListUserGroups"]); + toast({ title: "Renamed User Group" }); + }, + }).mutate; + const rename = () => { + if (!name) { + toast({ title: "New name cannot be empty" }); + if (group) setName(group.name); + return; + } + renameMutate({ id: group_id, name }); + }; + const removeMutate = useWrite("RemoveUserFromUserGroup", { + onSuccess: () => { + inv(["ListUserGroups"]); + toast({ title: "Removed User from User Group" }); + }, + }).mutate; + if (!group) return null; + return ( + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") rename(); + }} + className="text-3xl h-fit p-2" + /> + {name !== group.name && ( + + )} + + } + > +
} + actions={} + > + + group ? group.users.includes(user._id?.$oid!) : false + ) ?? [] + } + onUserRemove={(user_id) => + removeMutate({ user_group: group_id, user_id }) + } + /> +
+ +
+ ); +}; + +const AddUserToGroup = ({ group_id }: { group_id: string }) => { + const inv = useInvalidate(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + const [input, setInput] = useState(""); + + const group = useRead("ListUserGroups", {}).data?.find( + (group) => group._id?.$oid === group_id + ); + + const users = useRead("ListUsers", {}).data?.filter( + (user) => + // Only show users not already in group + !group?.users.includes(user._id?.$oid!) + ); + + const addUser = useWrite("AddUserToUserGroup", { + onSuccess: () => { + inv(["ListUserGroups"]); + toast({ title: "Added User to User Group" }); + }, + }).mutate; + + if (!users || users.length === 0) return null; + + return ( + + + + + + + + + + No Users Found + + + + + {users?.map((user) => ( + { + setOpen(false); + addUser({ user_group: group_id, user_id: user._id?.$oid! }); + }} + > + + + ))} + + + + + + ); +}; + +const UserAvatar = ({ avatar }: { avatar: string | undefined }) => + avatar ? ( + Avatar + ) : ( + + ); diff --git a/frontend/src/pages/user.tsx b/frontend/src/pages/user.tsx new file mode 100644 index 000000000..0789603ca --- /dev/null +++ b/frontend/src/pages/user.tsx @@ -0,0 +1,105 @@ +import { KeysTable } from "@components/keys/table"; +import { Page, Section } from "@components/layouts"; +import { PermissionsTable } from "@components/users/permissions-table"; +import { + CreateKeyForServiceUser, + DeleteKeyForServiceUser, +} from "@components/users/service-api-key"; +import { ConfirmButton } from "@components/util"; +import { useInvalidate, useRead, useWrite } from "@lib/hooks"; +import { Label } from "@ui/label"; +import { Switch } from "@ui/switch"; +import { useToast } from "@ui/use-toast"; +import { Key, UserCheck, UserMinus } from "lucide-react"; +import { useParams } from "react-router-dom"; + +export const UserPage = () => { + const { toast } = useToast(); + const inv = useInvalidate(); + const user_id = useParams().id as string; + const user = useRead("ListUsers", {}).data?.find( + (user) => user._id?.$oid === user_id + ); + const { mutate } = useWrite("UpdateUserBasePermissions", { + onSuccess: () => inv(["ListUsers"]), + onError: (e) => { + console.log(e); + toast({ title: "Failed to update user permissions" }); + }, + }); + const enabledClass = user?.enabled ? "text-green-500" : "text-red-500"; + const avatar = (user?.config.data as any)?.avatar as string | undefined; + if (!user) return null; + return ( + + {user?.username}{" "} + {avatar && } + + } + subtitle={ +
+
+ {user?.enabled ? "Enabled" : "Disabled"} +
+ |
Level: {user?.admin ? "Admin" : "User"}
| +
Type: {user?.config.type}
+
+ } + actions={ + !user.admin && ( +
+ {(["Server", "Build"] as Array<"Server" | "Build">).map((item) => { + const key = `create_${item.toLowerCase()}_permissions` as + | "create_server_permissions" + | "create_build_permissions"; + const req_key = `create_${item.toLowerCase()}s`; + return ( +
+ + mutate({ user_id, [req_key]: !user[key] })} + /> +
+ ); + })} + + ) : ( + + ) + } + variant={user.enabled ? "destructive" : "outline"} + onClick={() => mutate({ user_id, enabled: !user.enabled })} + /> +
+ ) + } + > + {user.config.type === "Service" && } + {!user.admin && ( + + )} +
+ ); +}; + +const ApiKeysTable = ({ user_id }: { user_id: string }) => { + const keys = useRead("ListApiKeysForServiceUser", { user_id }).data; + return ( +
} + actions={} + > + +
+ ); +}; diff --git a/frontend/src/pages/users.tsx b/frontend/src/pages/users.tsx index 8a318c9c1..c9d984090 100644 --- a/frontend/src/pages/users.tsx +++ b/frontend/src/pages/users.tsx @@ -1,18 +1,12 @@ import { Page, Section } from "@components/layouts"; import { NewServiceUser, NewUserGroup } from "@components/users/new"; -import { PermissionsTable } from "@components/users/permissions-table"; -import { ConfirmButton } from "@components/util"; -import { text_color_class_by_intention } from "@lib/color"; -import { useInvalidate, useRead, useSetTitle, useWrite } from "@lib/hooks"; -import { Button } from "@ui/button"; +import { UserTable } from "@components/users/table"; +import { useRead, useSetTitle } from "@lib/hooks"; import { DataTable } from "@ui/data-table"; import { Input } from "@ui/input"; -import { Label } from "@ui/label"; -import { Switch } from "@ui/switch"; -import { useToast } from "@ui/use-toast"; -import { Save, UserCheck, UserMinus } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { UserCircle2, Users } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; export const UsersPage = () => { useSetTitle("Users"); @@ -33,7 +27,11 @@ export const UsersPage = () => { } > {/* User Groups */} -
}> +
} + actions={} + > {
{/* Users */} -
}> - } + actions={} + > + searchSplit.every((term) => user.username.includes(term)) ) ?? [] } - columns={[ - { header: "Username", accessorKey: "username" }, - { header: "Type", accessorKey: "config.type" }, - { - header: "Level", - accessorFn: (user) => (user.admin ? "Admin" : "User"), - }, - { - header: "Enabled", - cell: ({ row }) => { - const enabledClass = row.original.enabled - ? text_color_class_by_intention("Good") - : text_color_class_by_intention("Critical"); - return ( -
- {row.original.enabled ? "Enabled" : "Disabled"} -
- ); - }, - }, - ]} - onRowClick={(user) => nav(`/users/${user._id!.$oid}`)} />
); }; - -export const UserGroupPage = () => { - const { toast } = useToast(); - const inv = useInvalidate(); - const group_id = useParams().id as string; - const group = useRead("ListUserGroups", {}).data?.find( - (group) => group._id?.$oid === group_id - ); - const [name, setName] = useState(); - useEffect(() => { - if (group) setName(group.name); - }, [group?.name]); - const renameMutate = useWrite("RenameUserGroup", { - onSuccess: () => { - inv(["ListUserGroups"]); - toast({ title: "Renamed User Group" }); - }, - }).mutate; - const rename = () => { - if (!name) { - toast({ title: "New name cannot be empty" }); - if (group) setName(group.name); - return; - } - renameMutate({ id: group_id, name }); - }; - if (!group) return null; - return ( - - setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") rename(); - }} - className="text-3xl h-fit p-2" - /> - {name !== group.name && ( - - )} - - } - > - - - ); -}; - -export const UserPage = () => { - const { toast } = useToast(); - const inv = useInvalidate(); - const user_id = useParams().id as string; - const user = useRead("ListUsers", {}).data?.find( - (user) => user._id?.$oid === user_id - ); - const { mutate } = useWrite("UpdateUserBasePermissions", { - onSuccess: () => inv(["ListUsers"]), - onError: (e) => { - console.log(e); - toast({ title: "Failed to update user permissions" }); - }, - }); - const enabledClass = user?.enabled ? "text-green-500" : "text-red-500"; - const avatar = (user?.config.data as any)?.avatar as string | undefined; - if (!user) return null; - return ( - - {user?.username}{" "} - {avatar && } - - } - subtitle={ -
-
- {user?.enabled ? "Enabled" : "Disabled"} -
- |
Level: {user?.admin ? "Admin" : "User"}
| -
Type: {user?.config.type}
-
- } - actions={ - !user.admin && ( -
- {(["Server", "Build"] as Array<"Server" | "Build">).map((item) => { - const key = `create_${item.toLowerCase()}_permissions` as - | "create_server_permissions" - | "create_build_permissions"; - const req_key = `create_${item.toLowerCase()}s`; - return ( -
- - mutate({ user_id, [req_key]: !user[key] })} - /> -
- ); - })} - - ) : ( - - ) - } - variant={user.enabled ? "destructive" : "outline"} - onClick={() => mutate({ user_id, enabled: !user.enabled })} - /> -
- ) - } - > - {!user.admin && ( - - )} -
- ); -}; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 0e64c066b..b0b11ffc0 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -8,12 +8,14 @@ import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { Tree } from "@pages/home/tree"; import { Tags } from "@pages/tags"; import { ResourceUpdates } from "@pages/resource_update"; -import { UserGroupPage, UserPage, UsersPage } from "@pages/users"; +import { UsersPage } from "@pages/users"; import { AllResources } from "@pages/home/all_resources"; import { UserDisabled } from "@pages/user_disabled"; import { Home } from "@pages/home"; import { ResourceStats } from "@pages/resource_stats"; import { Alerts } from "@pages/alerts"; +import { UserPage } from "@pages/user"; +import { UserGroupPage } from "@pages/user-group"; const ROUTER = createBrowserRouter([ {