manage user groups

This commit is contained in:
mbecker20
2024-04-21 17:37:31 -07:00
parent 4894568651
commit 43514acc92
11 changed files with 438 additions and 211 deletions
+1 -1
View File
@@ -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;
+53
View File
@@ -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 (
<DataTable
tableKey="api-keys"
data={keys}
columns={[
{ header: "Name", accessorKey: "name" },
{
header: "Key",
cell: ({
row: {
original: { key },
},
}) => {
return (
<div className="flex items-center gap-2">
<Input className="w-40" value={key} disabled />
<CopyButton content={key} />
</div>
);
},
},
{
header: "Expires",
accessorFn: ({ expires }) =>
expires
? "In " +
((expires - Date.now()) / ONE_DAY_MS).toFixed() +
" Days"
: "Never",
},
{
header: "Delete",
cell: ({ row }) => <DeleteKey api_key={row.original.key} />,
},
]}
/>
);
};
+8 -5
View File
@@ -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"
? [<AlertTriangle className="w-4 h-4" />, "Alerts"]
: location.pathname.split("/")[1] === "user-groups"
? [<Group className="w-4 h-4" />, "User Groups"]
? [<Users className="w-4 h-4" />, "User Groups"]
: location.pathname === "/users" ||
location.pathname.split("/")[1] === "users"
? [<UserCircle2 className="w-4 h-4" />, "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"
>
<Group className="w-4 h-4" />
<Users className="w-4 h-4" />
{selected ? selected.name : "All User Groups"}
</Button>
</PopoverTrigger>
@@ -351,7 +351,7 @@ const UserGroupDropdown = ({ group_id }: { group_id: string | undefined }) => {
variant="link"
className="flex gap-2 items-center p-0"
>
<Group className="w-4 h-4" />
<Users className="w-4 h-4" />
{group.name}
</Button>
</CommandItem>
@@ -379,7 +379,10 @@ const UsersDropdown = ({ user_id }: { user_id: string | undefined }) => {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" className="flex justify-start items-center gap-2 w-48 px-3">
<Button
variant="ghost"
className="flex justify-start items-center gap-2 w-48 px-3"
>
<UserAvatar avatar={avatar} />
{selected ? selected.username : "All Users"}
</Button>
@@ -57,7 +57,7 @@ export const CreateKeyForServiceUser = ({ user_id }: { user_id: string }) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button className="items-center gap-2">
<Button variant="secondary" className="items-center gap-2">
New Api Key <PlusCircle className="w-4 h-4" />
</Button>
</DialogTrigger>
+62
View File
@@ -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<Types.User, "User" | "Admin">[] = [
{ 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 (
<div className={enabledClass}>
{row.original.enabled ? "Enabled" : "Disabled"}
</div>
);
},
},
];
if (onUserRemove) {
columns.push({
header: "Remove",
cell: ({ row }) => (
<ConfirmButton
title="Remove"
variant="destructive"
icon={<MinusCircle className="w-4 h-4" />}
onClick={(e) => {
e.stopPropagation();
onUserRemove(row.original._id?.$oid!);
}}
/>
),
});
}
return (
<DataTable
tableKey="users"
data={users}
columns={columns}
onRowClick={(user) => nav(`/users/${user._id!.$oid}`)}
/>
);
};
+5 -1
View File
@@ -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}
+6 -36
View File
@@ -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 (
<Page title="Api Keys" actions={<CreateKey />}>
<DataTable
tableKey="api-keys"
data={keys}
columns={[
{ header: "Name", accessorKey: "name" },
{
header: "Key",
cell: ({
row: {
original: { key },
},
}) => {
return (
<div className="flex items-center gap-2">
<Input className="w-40" value={key} disabled />
<CopyButton content={key} />
</div>
);
},
},
{
header: "Expires",
accessorFn: ({ expires }) =>
expires
? "In " +
((expires - Date.now()) / ONE_DAY_MS).toFixed() +
" Days"
: "Never",
},
{
header: "Delete",
cell: ({ row }) => <DeleteKey api_key={row.original.key} />,
},
]}
<KeysTable
keys={keys}
DeleteKey={DeleteKey}
/>
</Page>
);
@@ -102,7 +72,7 @@ const CreateKey = () => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button className="items-center gap-2">
<Button variant="secondary" className="items-center gap-2">
New Api Key <PlusCircle className="w-4 h-4" />
</Button>
</DialogTrigger>
+177
View File
@@ -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<string>();
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 (
<Page
title={
<div className="flex gap-4 items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") rename();
}}
className="text-3xl h-fit p-2"
/>
{name !== group.name && (
<Button size="icon" onClick={rename}>
<Save className="w-4 h-4" />
</Button>
)}
</div>
}
>
<Section
title="Users"
icon={<UserCircle2 className="w-4 h-4" />}
actions={<AddUserToGroup group_id={group_id} />}
>
<UserTable
users={
users?.filter((user) =>
group ? group.users.includes(user._id?.$oid!) : false
) ?? []
}
onUserRemove={(user_id) =>
removeMutate({ user_group: group_id, user_id })
}
/>
</Section>
<PermissionsTable user_target={{ type: "UserGroup", id: group_id }} />
</Page>
);
};
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
className="flex justify-start items-center gap-2 w-48 px-3"
>
<PlusCircle className="w-4 h-4" />
Add User
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] max-h-[400px] p-0" sideOffset={12}>
<Command>
<CommandInput
placeholder="Search Users"
className="h-9"
value={input}
onValueChange={setInput}
/>
<CommandList>
<CommandEmpty className="flex justify-evenly items-center">
No Users Found
<SearchX className="w-3 h-3" />
</CommandEmpty>
<CommandGroup>
{users?.map((user) => (
<CommandItem
key={user.username}
onSelect={() => {
setOpen(false);
addUser({ user_group: group_id, user_id: user._id?.$oid! });
}}
>
<Button
variant="ghost"
className="flex gap-2 items-center p-0"
>
<UserAvatar avatar={(user.config.data as any).avatar} />
{user.username}
</Button>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
const UserAvatar = ({ avatar }: { avatar: string | undefined }) =>
avatar ? (
<img src={avatar} alt="Avatar" className="w-4" />
) : (
<UserCircle2 className="w-4" />
);
+105
View File
@@ -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 (
<Page
title={
<div className="flex gap-4 items-center">
{user?.username}{" "}
{avatar && <img src={avatar} alt="" className="w-7 h-7" />}
</div>
}
subtitle={
<div className="text-sm text-muted-foreground flex gap-2">
<div className={enabledClass}>
{user?.enabled ? "Enabled" : "Disabled"}
</div>
|<div className="">Level: {user?.admin ? "Admin" : "User"}</div>|
<div className="">Type: {user?.config.type}</div>
</div>
}
actions={
!user.admin && (
<div className="flex gap-4 items-center">
{(["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 (
<div key={key} className="flex gap-2 items-center">
<Label htmlFor={key}>Create {item}</Label>
<Switch
id={key}
className="flex gap-4"
checked={user[key]}
onClick={() => mutate({ user_id, [req_key]: !user[key] })}
/>
</div>
);
})}
<ConfirmButton
title={user.enabled ? "Disable User" : "Enable User"}
icon={
user.enabled ? (
<UserMinus className="w-4 h-4" />
) : (
<UserCheck className="w-4 h-4" />
)
}
variant={user.enabled ? "destructive" : "outline"}
onClick={() => mutate({ user_id, enabled: !user.enabled })}
/>
</div>
)
}
>
{user.config.type === "Service" && <ApiKeysTable user_id={user_id} />}
{!user.admin && (
<PermissionsTable user_target={{ type: "User", id: user_id }} />
)}
</Page>
);
};
const ApiKeysTable = ({ user_id }: { user_id: string }) => {
const keys = useRead("ListApiKeysForServiceUser", { user_id }).data;
return (
<Section
title="Api Keys"
icon={<Key className="w-4 h-4" />}
actions={<CreateKeyForServiceUser user_id={user_id} />}
>
<KeysTable keys={keys ?? []} DeleteKey={DeleteKeyForServiceUser} />
</Section>
);
};
+17 -166
View File
@@ -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 */}
<Section title="User Groups" actions={<NewUserGroup />}>
<Section
title="User Groups"
icon={<Users className="w-4 h-4" />}
actions={<NewUserGroup />}
>
<DataTable
tableKey="user-groups"
data={
@@ -53,166 +51,19 @@ export const UsersPage = () => {
</Section>
{/* Users */}
<Section title="Users" actions={<NewServiceUser />}>
<DataTable
tableKey="users"
data={
<Section
title="Users"
icon={<UserCircle2 className="w-4 h-4" />}
actions={<NewServiceUser />}
>
<UserTable
users={
users?.filter((user) =>
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 (
<div className={enabledClass}>
{row.original.enabled ? "Enabled" : "Disabled"}
</div>
);
},
},
]}
onRowClick={(user) => nav(`/users/${user._id!.$oid}`)}
/>
</Section>
</Page>
);
};
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<string>();
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 (
<Page
title={
<div className="flex gap-4 items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") rename();
}}
className="text-3xl h-fit p-2"
/>
{name !== group.name && (
<Button size="icon" onClick={rename}>
<Save className="w-4 h-4" />
</Button>
)}
</div>
}
>
<PermissionsTable user_target={{ type: "UserGroup", id: group_id }} />
</Page>
);
};
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 (
<Page
title={
<div className="flex gap-4 items-center">
{user?.username}{" "}
{avatar && <img src={avatar} alt="" className="w-7 h-7" />}
</div>
}
subtitle={
<div className="text-sm text-muted-foreground flex gap-2">
<div className={enabledClass}>
{user?.enabled ? "Enabled" : "Disabled"}
</div>
|<div className="">Level: {user?.admin ? "Admin" : "User"}</div>|
<div className="">Type: {user?.config.type}</div>
</div>
}
actions={
!user.admin && (
<div className="flex gap-4 items-center">
{(["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 (
<div key={key} className="flex gap-2 items-center">
<Label htmlFor={key}>Create {item}</Label>
<Switch
id={key}
className="flex gap-4"
checked={user[key]}
onClick={() => mutate({ user_id, [req_key]: !user[key] })}
/>
</div>
);
})}
<ConfirmButton
title={user.enabled ? "Disable User" : "Enable User"}
icon={
user.enabled ? (
<UserMinus className="w-4 h-4" />
) : (
<UserCheck className="w-4 h-4" />
)
}
variant={user.enabled ? "destructive" : "outline"}
onClick={() => mutate({ user_id, enabled: !user.enabled })}
/>
</div>
)
}
>
{!user.admin && (
<PermissionsTable user_target={{ type: "User", id: user_id }} />
)}
</Page>
);
};
+3 -1
View File
@@ -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([
{