Browse Source

feat: User admin section

pull/33/head
Dale 2 weeks ago
parent
commit
19315ae10f
Signed by: Deiru GPG Key ID: AA250C0277B927E1
  1. 8
      .graphqlrc
  2. 3871
      graphql.schema.json
  3. 1
      package.json
  4. 33
      src/cli/commands/fix-user-flags.ts
  5. 6
      src/cli/index.ts
  6. 208
      src/client/AdminPanel/Users/UserModal.tsx
  7. 66
      src/client/AdminPanel/Users/const.ts
  8. 457
      src/client/AdminPanel/Users/index.tsx
  9. 47
      src/client/components/form/FormCheckbox.tsx
  10. 29
      src/client/components/form/FormGroup.tsx
  11. 52
      src/client/components/ui/Filter.tsx
  12. 141
      src/client/hooks/pagination.tsx
  13. 3
      src/pages/admin/[[...path]].tsx
  14. 62
      src/server/db/models/Users.ts
  15. 8
      src/server/db/models/types.d.ts
  16. 24
      src/server/graphql/errors/auth.ts
  17. 13
      src/server/graphql/index.ts
  18. 77
      src/server/graphql/mutations/full-users/index.ts
  19. 17
      src/server/graphql/mutations/users/auth.ts
  20. 3
      src/server/graphql/mutations/users/index.ts
  21. 51
      src/server/graphql/resolvers/full-users/index.ts
  22. 87
      src/server/graphql/schema/FullUser.graphql
  23. 2
      src/server/graphql/schema/User.graphql
  24. 4330
      src/shared/graphql.ts
  25. 645
      src/shared/hooks.ts

8
.graphqlrc

@ -0,0 +1,8 @@
schema: 'src/server/graphql/schema/*.graphql'
documents:
- 'src/pages/**/*.{tsx,ts}'
- 'src/client/**/*.{tsx,ts}'
extensions:
endpoints:
example:
url: 'http://localhost:8000'

3871
graphql.schema.json
File diff suppressed because it is too large
View File

1
package.json

@ -11,6 +11,7 @@
"dev:server": "nodemon -e ts,tsx,graphql --ignore src/shared/theme --ignore src/client --ignore src/pages --exec \"ts-node -r tsconfig-paths/register src/server/server.ts\"",
"run:server": "NODE_ENV=production ts-node -r tsconfig-paths/register src/server/server.ts",
"run:ctl": "NODE_ENV=production node dist/cli/index.js",
"dev:ctl": "ts-node -r tsconfig-paths/register src/cli/index.ts",
"build:server": "ncc build src/server/server.ts -o ./dist/server",
"build:client": "next build",
"build:client:docker": "NODE_ENV=docker next build",

33
src/cli/commands/fix-user-flags.ts

@ -0,0 +1,33 @@
import * as rdb from "rethinkdb";
import config from "miracle-tv-server/config";
export const fixUserFlags = async () => {
const conn = await rdb.connect({
host: config.database?.host,
port: config.database?.port,
});
const users = await rdb
.db(config.database?.db)
.table("users")
.filter({})
.coerceTo("array")
.run(conn);
await Promise.all(
users.map(async (user: any) => {
let update: any = {};
if (user.disabled === undefined) update.disabled = false;
if (user.suspended === undefined) update.suspended = false;
if (user.loginDisabled === undefined) update.loginDisabled = false;
if (user.silenced === undefined) update.silenced = false;
return rdb
.db(config.database?.db)
.table("users")
.get(user.id)
.update(update)
.run(conn);
})
);
conn.close();
};

6
src/cli/index.ts

@ -1,6 +1,7 @@
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { changePassword } from "./commands/change-password";
import { fixUserFlags } from "./commands/fix-user-flags";
import { getDbStats } from "./commands/stats";
yargs(hideBin(process.argv))
@ -16,5 +17,10 @@ yargs(hideBin(process.argv))
describe: "Get statistics for database in use",
handler: getDbStats,
})
.command({
command: "fix-user-flags",
describe: "Fix missing on users",
handler: fixUserFlags,
})
.demandCommand()
.help().argv;

208
src/client/AdminPanel/Users/UserModal.tsx

@ -0,0 +1,208 @@
import { gql } from "@apollo/client";
import { UseDisclosureReturn } from "@chakra-ui/hooks";
import { AdminFullUserFragment } from "miracle-tv-shared/graphql";
import { ADMIN_FULL_USER_FRAGMENT } from "./const";
import {
Modal,
ModalOverlay,
ModalContent,
Image,
ModalBody,
ModalCloseButton,
Box,
Flex,
AspectRatio,
Divider,
Button,
Heading,
Text,
IconButton,
SimpleGrid,
useToast,
} from "@chakra-ui/react";
import React, { useCallback } from "react";
import { getMediaURL } from "miracle-tv-shared/media";
import { Avatar } from "miracle-tv-client/components/ui/Avatar";
import { CloseIcon } from "@chakra-ui/icons";
import { useUpdateFullUserMutation } from "miracle-tv-shared/hooks";
type Props = {
user: AdminFullUserFragment | null;
} & Partial<UseDisclosureReturn>;
gql`
mutation UpdateFullUser($input: UpdateFullUserInput!) {
updateFullUser(input: $input) {
...AdminFullUser
}
}
${ADMIN_FULL_USER_FRAGMENT}
`;
type UpdateFields = "deleted" | "silenced" | "suspended" | "loginDisabled";
export const UserModal = ({ user, onClose }: Props) => {
const toast = useToast();
const [updateFullUserMutation] = useUpdateFullUserMutation({
onCompleted() {
toast({ status: "success", title: "Updated user" });
},
onError() {
toast({ status: "error", title: "There was an error updated user" });
},
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
});
const onUpdate = useCallback(
(field: UpdateFields, value: boolean) => {
updateFullUserMutation({
variables: { input: { id: user?.id, [field]: value } },
});
},
[updateFullUserMutation, user]
);
return (
<Modal isOpen={!!user} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalBody p={0}>
<ModalCloseButton
as={(props) => (
<IconButton aria-label="close" icon={<CloseIcon />} {...props} />
)}
_hover={{
backgroundColor: "primary.500",
color: "white",
}}
zIndex={10}
/>
<Flex position="relative">
<AspectRatio w="100%" ratio={16 / 6} zIndex={1}>
<Image
src={getMediaURL(user?.header?.filename)}
borderTopRadius="5px"
objectPosition="center"
/>
</AspectRatio>
<Flex
zIndex={2}
w="100%"
px={2}
py={1}
align="center"
bottom="-2rem"
position="absolute"
>
<Avatar
borderRadius="50%"
username={user?.username}
useGravatar={user?.settings?.useGravatar}
aspectMaxH="70px"
aspectMaxW="70px"
imageId={user?.avatar?.filename}
bgColor="white"
useAspectRatio={false}
borderLeftWidth="1px"
borderRightWidth="1px"
borderTopWidth="1px"
borderStyle="solid"
borderColor="primary.200"
/>
</Flex>
</Flex>
<Box
px={4}
py={4}
borderLeftWidth="1px"
borderRightWidth="1px"
borderBottomWidth="1px"
borderStyle="solid"
borderColor="primary.500"
borderBottomRadius="5px"
>
<Heading
size="md"
display="flex"
align="center"
mb={2}
mt="2rem"
py={1}
>
{user?.displayName || user?.username}
</Heading>
<Flex direction="column" mb={2}>
<Text fontWeight="bold">Bio:</Text>
<Text>{user?.bio || "No bio"}</Text>
</Flex>
<Flex direction="row" mb={2}>
<Text fontWeight="bold" mr={1}>
E-mail:
</Text>
<Text>{user?.email}</Text>
</Flex>
<Flex direction="row" mb={2}>
<Text fontWeight="bold" mr={1}>
Uses gravatar?:
</Text>
<Text>{user?.settings?.useGravatar ? "Yes" : "No"}</Text>
</Flex>
<Flex direction="row" mb={2}>
<Text fontWeight="bold" mr={1}>
Single User Mode?:
</Text>
<Text>{user?.settings?.singleUserMode ? "Yes" : "No"}</Text>
</Flex>
{user?.settings?.singleUserMode && (
<Flex direction="row" mb={2}>
<Text fontWeight="bold" mr={1}>
Single User Channel:
</Text>
{!user?.settings?.singleUserChannel && <Text>Not set</Text>}
{user?.settings?.singleUserChannel && (
<Text>{user?.settings?.singleUserChannel.name}</Text>
)}
</Flex>
)}
<Flex direction="row" mb={4}>
<Text fontWeight="bold" mr={1}>
Featured in profile directory?:
</Text>
<Text>{user?.settings?.featureInDirectory ? "Yes" : "No"}</Text>
</Flex>
<Heading size="sm" mb={2}>
User actions
</Heading>
<Divider mb={2} />
<SimpleGrid columns={2} spacing={2}>
<Button
onClick={() => onUpdate("suspended", !user?.suspended)}
colorScheme={!user?.suspended ? "red" : undefined}
>
{!user?.suspended ? "Suspend" : "Unsuspend"}
</Button>
<Button
onClick={() => onUpdate("loginDisabled", !user?.loginDisabled)}
colorScheme={!user?.loginDisabled ? "red" : undefined}
>
{!user?.loginDisabled ? "Disable Login" : "Enable Login"}
</Button>
<Button
onClick={() => onUpdate("deleted", !user?.deleted)}
colorScheme={!user?.deleted ? "red" : undefined}
>
{!user?.deleted ? "Delete" : "Restore"}
</Button>
<Button
onClick={() => onUpdate("silenced", !user?.silenced)}
colorScheme={!user?.silenced ? "red" : undefined}
>
{!user?.silenced ? "Silence" : "Unsilence"}
</Button>
</SimpleGrid>
</Box>
</ModalBody>
{/* <ModalFooter></ModalFooter> */}
</ModalContent>
</Modal>
);
};

66
src/client/AdminPanel/Users/const.ts

@ -0,0 +1,66 @@
import { gql } from "@apollo/client";
export const ADMIN_FULL_USER_FRAGMENT = gql`
fragment AdminFullUser on FullUser {
id
username
displayName
bio
email
roles {
id
name
access {
rights {
channels
streamKeys
roles
users
activities
userSettings
}
actions {
user {
silence
ban
warn
}
}
}
parentId
}
channels {
id
name
}
avatar {
id
filename
}
header {
id
filename
}
streamThumbnail {
id
filename
}
silenced
suspended
deleted
loginDisabled
settings {
id
useGravatar
singleUserMode
singleUserChannel {
id
name
}
featureInDirectory
}
meta {
followerCount
}
}
`;

457
src/client/AdminPanel/Users/index.tsx

@ -0,0 +1,457 @@
import { gql } from "@apollo/client";
import { ChevronDownIcon, EditIcon } from "@chakra-ui/icons";
import {
Box,
Divider,
Heading,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Checkbox,
IconButton,
HStack,
useToast,
Menu,
MenuButton,
MenuList,
MenuItem,
Button,
MenuDivider,
} from "@chakra-ui/react";
import { FormCheckbox } from "miracle-tv-client/components/form/FormCheckbox";
import { FormInput } from "miracle-tv-client/components/form/FormInput";
import { Filter } from "miracle-tv-client/components/ui/Filter";
import { Loading } from "miracle-tv-client/components/ui/Loading";
import { Pagination, usePagination } from "miracle-tv-client/hooks/pagination";
import {
AdminFullUserFragment,
FullUsersFilter,
} from "miracle-tv-shared/graphql";
import {
useFullUserAdminQuery,
useFullUserAdminCountQuery,
useBulkDeleteUsersMutation,
useBulkRestoreUsersMutation,
useBulkSuspendUsersMutation,
useBulkUnsuspendUsersMutation,
useBulkDisableLoginsMutation,
useBulkEnableLoginsMutation,
useBulkSilenceUsersMutation,
useBulkUnsilenceUsersMutation,
} from "miracle-tv-shared/hooks";
import { uniq } from "ramda";
import React, { useCallback, useMemo, useState, useEffect } from "react";
import { ADMIN_FULL_USER_FRAGMENT } from "./const";
import { UserModal } from "./UserModal";
gql`
query FullUserAdmin($filter: FullUsersFilter, $limit: QueryLimit) {
fullUsers(filter: $filter, limit: $limit) {
...AdminFullUser
}
}
query FullUserAdminCount($filter: FullUsersFilter) {
fullUserCount(filter: $filter)
}
${ADMIN_FULL_USER_FRAGMENT}
`;
gql`
mutation BulkDeleteUsers($ids: [ID]!) {
deleteFullUsers(ids: $ids) {
...AdminFullUser
}
}
mutation BulkRestoreUsers($ids: [ID]!) {
restoreFullUsers(ids: $ids) {
...AdminFullUser
}
}
mutation BulkSuspendUsers($ids: [ID]!) {
suspendFullUsers(ids: $ids) {
...AdminFullUser
}
}
mutation BulkUnsuspendUsers($ids: [ID]!) {
unsuspendFullUsers(ids: $ids) {
...AdminFullUser
}
}
mutation BulkDisableLogins($ids: [ID]!) {
disableFullUsersLogin(ids: $ids) {
...AdminFullUser
}
}
mutation BulkEnableLogins($ids: [ID]!) {
enableFullUsersLogin(ids: $ids) {
...AdminFullUser
}
}
mutation BulkSilenceUsers($ids: [ID]!) {
silenceFullUsers(ids: $ids) {
...AdminFullUser
}
}
mutation BulkUnsilenceUsers($ids: [ID]!) {
unsilenceFullUsers(ids: $ids) {
...AdminFullUser
}
}
${ADMIN_FULL_USER_FRAGMENT}
`;
const perPage = 6;
const defaultFilter = {};
export const AdminUserList = () => {
const toast = useToast();
const [filter, setFilter] = useState<FullUsersFilter>(defaultFilter);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [editableUserId, setEditableUserId] = useState<string | null>(null);
const { data: { fullUserCount = 0 } = {} } = useFullUserAdminCountQuery({
variables: { filter },
});
const pagination = usePagination(fullUserCount, perPage);
const {
data: { fullUsers = [] } = {},
loading: isLoading,
refetch,
} = useFullUserAdminQuery({
variables: {
filter: filter,
limit: { limit: perPage, skip: pagination.targetSkip },
},
fetchPolicy: "no-cache",
});
const editableUser: AdminFullUserFragment | null = useMemo(() => {
return fullUsers.find((user) => user.id === editableUserId) || null;
}, [editableUserId, fullUsers]);
const onUserModalOpen = useCallback(
(id: string) => {
setEditableUserId(id);
},
[setEditableUserId]
);
const onUserModalClose = useCallback(() => {
setEditableUserId(null);
}, [setEditableUserId]);
useEffect(() => {
setSelectedUsers([]);
}, [fullUsers, setSelectedUsers]);
const toggleUser = useCallback(
(id: string) => {
if (selectedUsers.includes(id)) {
setSelectedUsers(selectedUsers.filter((userId) => userId !== id));
} else {
setSelectedUsers(uniq([...selectedUsers, id]));
}
},
[selectedUsers, setSelectedUsers]
);
const toggleAllUsers = useCallback(() => {
if (selectedUsers.length < fullUsers.length) {
setSelectedUsers(fullUsers.map((user) => user.id));
} else if (selectedUsers.length === fullUsers.length) {
setSelectedUsers([]);
}
}, [selectedUsers, setSelectedUsers, fullUsers]);
const isChecked = useCallback(
(id: string) => selectedUsers.includes(id),
[selectedUsers]
);
const [deleteBulkUserMutaiton] = useBulkDeleteUsersMutation({
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
onCompleted() {
toast({ status: "success", title: "Disabled users!" });
},
onError() {
toast({ status: "error", title: "There was an error disabling users!" });
},
});
const onBulkDelete = useCallback(() => {
deleteBulkUserMutaiton({ variables: { ids: selectedUsers } });
}, [deleteBulkUserMutaiton, selectedUsers]);
const [restoreBulkUserMutaiton] = useBulkRestoreUsersMutation({
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
onCompleted() {
toast({ status: "success", title: "Restored users!" });
},
onError() {
toast({ status: "error", title: "There was an error restoring users!" });
},
});
const onBulkRestore = useCallback(() => {
restoreBulkUserMutaiton({ variables: { ids: selectedUsers } });
}, [deleteBulkUserMutaiton, selectedUsers]);
const [suspendBulkUserMutation] = useBulkSuspendUsersMutation({
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
onCompleted() {
toast({ status: "success", title: "Suspended users!" });
},
onError() {
toast({ status: "error", title: "There was an error suspending users!" });
},
});
const onBulkSuspend = useCallback(() => {
suspendBulkUserMutation({ variables: { ids: selectedUsers } });
}, [deleteBulkUserMutaiton, selectedUsers]);
const [unsuspendBulkUserMutation] = useBulkUnsuspendUsersMutation({
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
onCompleted() {
toast({ status: "success", title: "Unsuspended users!" });
},
onError() {
toast({
status: "error",
title: "There was an error Unsuspending users!",
});
},
});
const onBulkUnsuspend = useCallback(() => {
unsuspendBulkUserMutation({ variables: { ids: selectedUsers } });
}, [deleteBulkUserMutaiton, selectedUsers]);
const [disableLoginBulkMutation] = useBulkDisableLoginsMutation({
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
onCompleted() {
toast({ status: "success", title: "Disabled login for users!" });
},
onError() {
toast({
status: "error",
title: "There was an error Disabling login for users!",
});
},
});
const onBulkLoginDisable = useCallback(() => {
disableLoginBulkMutation({ variables: { ids: selectedUsers } });
}, [deleteBulkUserMutaiton, selectedUsers]);
const [enableLoginBulkMutation] = useBulkEnableLoginsMutation({
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
onCompleted() {
toast({ status: "success", title: "Enabled login for users!" });
},
onError() {
toast({
status: "error",
title: "There was an error Enabling login for users!",
});
},
});
const onBulkLoginEnable = useCallback(() => {
enableLoginBulkMutation({ variables: { ids: selectedUsers } });
}, [deleteBulkUserMutaiton, selectedUsers]);
const [silenceBulkMutation] = useBulkSilenceUsersMutation({
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
onCompleted() {
toast({ status: "success", title: "Silenced users!" });
},
onError() {
toast({
status: "error",
title: "There was an error Silencing users!",
});
},
});
const onBulkSilence = useCallback(() => {
silenceBulkMutation({ variables: { ids: selectedUsers } });
}, [deleteBulkUserMutaiton, selectedUsers]);
const [unsilenceBulkMutation] = useBulkUnsilenceUsersMutation({
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
onCompleted() {
toast({ status: "success", title: "Unsilenced users!" });
},
onError() {
toast({
status: "error",
title: "There was an error Silencing users!",
});
},
});
const onBulkUnsilence = useCallback(() => {
unsilenceBulkMutation({ variables: { ids: selectedUsers } });
}, [deleteBulkUserMutaiton, selectedUsers]);
const bufferedUsers = useMemo(() => {
if (fullUsers.length < perPage) {
const bufferArray = Array(perPage - fullUsers.length).map(() => null);
return [...fullUsers, ...bufferArray];
}
return fullUsers;
}, [fullUsers]);
return (
<>
<Heading mb={4}>User Management</Heading>
<Filter<FullUsersFilter>
onFilter={setFilter}
defaultValues={filter}
refetch={refetch}
>
<Heading size="sm">Filter by fields</Heading>
<HStack mt={2}>
<FormInput name="username" label="Username" />
<FormInput name="email" label="E-mail" />
</HStack>
<Heading mt={3} size="sm">
Filter by states
</Heading>
<HStack mt={2}>
<FormCheckbox
name="suspended"
label="Suspended?"
w="initial"
uncheckUndefined
/>
<FormCheckbox
name="silenced"
label="Silenced?"
w="initial"
uncheckUndefined
/>
<FormCheckbox
name="loginDisabled"
label="Login disabled?"
w="initial"
uncheckUndefined
/>
<FormCheckbox
name="deleted"
label="Deleted?"
w="initial"
uncheckUndefined
/>
</HStack>
</Filter>
<Divider mb={4} />
<Menu>
<MenuButton
size="sm"
isDisabled={selectedUsers.length === 0}
as={Button}
mb={4}
rightIcon={<ChevronDownIcon />}
>
Bulk actions
</MenuButton>
<MenuList>
<MenuItem onClick={onBulkDelete}>Delete</MenuItem>
<MenuItem onClick={onBulkRestore}>Restore</MenuItem>
<MenuDivider />
<MenuItem onClick={onBulkSuspend}>Suspend</MenuItem>
<MenuItem onClick={onBulkUnsuspend}>Unsuspend</MenuItem>
<MenuDivider />
<MenuItem onClick={onBulkLoginDisable}>Disable Login</MenuItem>
<MenuItem onClick={onBulkLoginEnable}>Enable Login</MenuItem>
<MenuDivider />
<MenuItem onClick={onBulkSilence}>Silence</MenuItem>
<MenuItem onClick={onBulkUnsilence}>Unsilence</MenuItem>
</MenuList>
</Menu>
<UserModal user={editableUser} onClose={onUserModalClose} />
<Box position="relative">
{isLoading && (
<Box position="absolute" w="100%" h="100%" top={0} left={0}>
<Loading />
</Box>
)}
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th>
<Checkbox
isDisabled={fullUsers.length === 0}
isChecked={
selectedUsers.length &&
selectedUsers.length === fullUsers.length
}
isIndeterminate={
selectedUsers.length > 0 &&
selectedUsers.length < fullUsers.length
}
onChange={toggleAllUsers}
/>
</Th>
<Th />
<Th>Username</Th>
<Th>Display name</Th>
<Th>E-mail</Th>
<Th>Is suspended?</Th>
<Th>Is silenced?</Th>
<Th>Is login disabled?</Th>
<Th>Is deleted?</Th>
</Tr>
</Thead>
<Tbody>
{bufferedUsers?.map((user, index) => (
<Tr key={user?.id || index}>
<Td>
<Checkbox
isDisabled={!user}
isChecked={isChecked(user?.id)}
onChange={() => user && toggleUser(user.id)}
/>
</Td>
<Td>
<IconButton
isDisabled={!user}
p={0}
variant="ghost"
color="primary.200"
aria-label="Edit user"
icon={<EditIcon />}
onClick={() => onUserModalOpen(user?.id)}
/>
</Td>
<Td>{user?.username}</Td>
<Td>{user?.displayName ?? "-"}</Td>
<Td>{user?.email}</Td>
<Td>
<Checkbox isReadOnly isChecked={user?.suspended} />
</Td>
<Td>
<Checkbox isReadOnly isChecked={user?.silenced} />
</Td>
<Td>
<Checkbox isReadOnly isChecked={user?.loginDisabled} />
</Td>
<Td>
<Checkbox isReadOnly isChecked={user?.deleted} />
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Pagination {...pagination} />
</>
);
};

47
src/client/components/form/FormCheckbox.tsx

@ -0,0 +1,47 @@
import React from "react";
import { FormControlProps } from "@chakra-ui/form-control";
import { useField } from "react-final-form";
import { FormGroup, FormGroupChakraProps, FormGroupProps } from "./FormGroup";
import { Checkbox, CheckboxProps } from "@chakra-ui/react";
type Props = {
name: string;
label?: string;
error?: string;
help?: string;
inputProps?: CheckboxProps;
uncheckUndefined?: boolean;
} & FormControlProps &
FormGroupChakraProps &
Omit<FormGroupProps, "children">;
export const FormCheckbox = ({
name,
inputProps,
uncheckUndefined = false,
...formGroupProps
}: Props) => {
const { input } = useField(name, { type: "checkbox" });
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { checked },
} = event;
if (!checked && uncheckUndefined) {
input.onChange("");
} else {
input.onChange(event);
}
};
return (
<FormGroup name={name} {...formGroupProps}>
<Checkbox
{...input}
{...inputProps}
onChange={onChange}
defaultChecked={input.checked}
isChecked={input.checked}
/>
</FormGroup>
);
};

29
src/client/components/form/FormGroup.tsx

@ -12,12 +12,13 @@ export type FormGroupChakraProps = {
errorProps?: HelpTextProps;
};
type Props = {
export type FormGroupProps = {
name: string;
label?: string;
error?: string;
help?: string | React.ReactNode;
hideLabel?: boolean;
isInline?: boolean;
children: React.ReactNode | React.ReactNode[];
} & FormGroupChakraProps &
FormControlProps;
@ -29,14 +30,34 @@ export const FormGroup = ({
children,
labelProps,
errorProps,
isInline = false,
help,
hideLabel = false,
...controlProps
}: Props) => {
}: FormGroupProps) => {
const inlineLabelStyle = isInline
? {
display: "inline-block",
marginBottom: 0,
}
: {};
const inlineFormControlStyle = isInline
? {
display: "inline-flex",
justify: "center",
align: "center",
}
: {};
return (
<FormControl id={name} pt={0} mt={0} {...controlProps}>
<FormControl
id={name}
pt={0}
mt={0}
{...controlProps}
{...inlineFormControlStyle}
>
{!!label && !hideLabel && (
<FormLabel pt={0} mt={0} mb={1} {...labelProps}>
<FormLabel pt={0} mt={0} mb={1} {...labelProps} {...inlineLabelStyle}>
{label}
</FormLabel>
)}

52
src/client/components/ui/Filter.tsx

@ -0,0 +1,52 @@
import { Button, Flex } from "@chakra-ui/react";
import React from "react";
import { Form } from "react-final-form";
type Props<T = any> = {
defaultValues?: Partial<T>;
onFilter: (values: T) => void;
children: React.ReactNode;
refetch?: () => void;
};
export const Filter = <T extends any>({
onFilter,
defaultValues,
children,
refetch,
}: Props<T>) => {
return (
<Form<T> initialValues={defaultValues} onSubmit={onFilter}>
{({ handleSubmit, pristine, form: { reset } }) => (
<Flex w="100%" mb={2}>
<form
onSubmit={(e) => {
handleSubmit(e);
if (pristine) {
refetch?.();
}
}}
style={{ width: "100%" }}
>
{children}
<Flex justify="flex-end" width="100%" mt={2}>
<Button
colorScheme="red"
onClick={() => {
reset({});
handleSubmit({});
}}
mr={2}
>
Clear filter
</Button>
<Button colorScheme="primary" type="submit">
Filter
</Button>
</Flex>
</form>
</Flex>
)}
</Form>
);
};

141
src/client/hooks/pagination.tsx

@ -0,0 +1,141 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons";
import { Button, ButtonGroup, Flex, IconButton } from "@chakra-ui/react";
import React, { useCallback, useMemo, useState } from "react";
export type UsePaginationReturn = {
pageCount: number;
page: number;
targetSkip: number;
nextPage: () => void;
prevPage: () => void;
changePage: (page: number) => void;
};
export const usePagination = (
count: number,
limit: number = 25,
skip: number = limit
): UsePaginationReturn => {
const [page, setPage] = useState<number>(1);
const pageCount = useMemo(() => {
return Math.ceil(count / limit);
}, [count, limit]);
const targetSkip = useMemo(() => {
const realPage = page > 0 ? page - 1 : 0;
return skip * realPage;
}, [skip, page]);
const nextPage = useCallback(() => {
if (page + 1 > pageCount) {
setPage(pageCount);
return;
}
setPage(page + 1);
}, [page, pageCount, setPage]);
const prevPage = useCallback(() => {
if (page - 1 < 1) {
setPage(1);
return;
}
setPage(page - 1);
}, [page, pageCount, setPage]);
const changePage = useCallback(
(targetPage: number) => {
if (targetPage < 1) {
setPage(1);
return;
}
if (targetPage > pageCount) {
setPage(pageCount);
return;
}
setPage(targetPage);
},
[page, pageCount, setPage]
);
return {
pageCount,
page,
targetSkip,
nextPage,
prevPage,
changePage,
};
};
type PaginationProps = {
pagesOnDisplay?: number;
} & UsePaginationReturn;
export const Pagination = ({
pagesOnDisplay = 5,
page,
pageCount,
changePage,
nextPage,
prevPage,
}: PaginationProps) => {
const pageSlice = useMemo(() => {
let startPage: number,
endPage: number = 0;
if (pageCount < pagesOnDisplay) {
startPage = 1;
endPage = pageCount;
} else {
let maxPagesBeforeCurrentPage = Math.floor(pagesOnDisplay / 2);
let maxPagesAfterCurrentPage = Math.ceil(pagesOnDisplay / 2) - 1;
if (page <= maxPagesBeforeCurrentPage) {
// current page near the start
startPage = 1;
endPage = pagesOnDisplay;
} else if (page + maxPagesAfterCurrentPage >= pageCount) {
// current page near the end
startPage = pageCount - pagesOnDisplay + 1;
endPage = pageCount;
} else {
// current page somewhere in the middle
startPage = page - maxPagesBeforeCurrentPage;
endPage = page + maxPagesAfterCurrentPage;
}
}
const pageSlice = Array.from(Array(endPage + 1 - startPage).keys()).map(
(i) => startPage + i
);
return pageSlice;
}, [pagesOnDisplay, page, pageCount]);
return (
<Flex w="100%" justify="center" mt={2}>
<ButtonGroup size="md" isAttached variant="outline">
<IconButton
isDisabled={page === 1}
borderColor="primary.500"
aria-label="Add to friends"
icon={<ChevronLeftIcon />}
onClick={prevPage}
/>
{pageSlice.map((pageNumber) => (
<Button
key={pageNumber}
borderColor={page === pageNumber ? "primary.200" : "primary.500"}
onClick={() => changePage(pageNumber)}
>
{pageNumber}
</Button>
))}
<IconButton
isDisabled={page === pageCount}
borderColor="primary.500"
aria-label="Add to friends"
icon={<ChevronRightIcon />}
onClick={nextPage}
/>
</ButtonGroup>
</Flex>
);
};

3
src/pages/admin/[[...path]].tsx

@ -7,10 +7,11 @@ import {
} from "miracle-tv-client/components/ui/Navigation";
import { AuthRedirect } from "miracle-tv-client/components/auth/Redirect";
import { AdminDashboard } from "miracle-tv-client/AdminPanel/AdminDashboard";
import { AdminUserList } from "miracle-tv-client/AdminPanel/Users";
const components: NavComponentMap = {
"/admin": { component: <AdminDashboard />, exact: true },
"/admin/users": { component: <>Users lmao</> },
"/admin/users": { component: <AdminUserList /> },
};
const nav: NavConfig = [

62
src/server/db/models/Users.ts

@ -21,6 +21,15 @@ import {
import { hash } from "bcrypt";
import { DbUser, DbUserSafe } from "miracle-tv-server/db/models/types";
const defaultUser = {
roles: ["user"],
singleUserMode: false,
loginDisabled: false,
disabled: false,
suspended: false,
silenced: false,
};
export class UsersModel extends Model {
table = db.table("users");
@ -42,8 +51,7 @@ export class UsersModel extends Model {
.insert({
...input,
password: hashed,
roles: ["user"],
singleUserMode: false,
...defaultUser,
})
.run(this.conn)
.then(async (result) => {
@ -52,8 +60,8 @@ export class UsersModel extends Model {
})) as User;
}
async getUserById(id: string): Promise<DbUser | null> {
return (await this.table.get(id).run(this.conn)) as DbUser | null;
async getUserById<T extends object = DbUser>(id: string): Promise<T | null> {
return this.table.get(id).run(this.conn) as T | null;
}
async getUsersForDirectory(limit?: QueryLimit): Promise<User[]> {
@ -97,10 +105,17 @@ export class UsersModel extends Model {
return this.sanitizeUser(user);
}
async getUsers(
{ ids, username, displayName, ...filter }: UsersFilter = {},
limit?: QueryLimit
): Promise<DbUser[]> {
userFilter(
{ ids, username, ...filter }: UsersFilter = {},
limit?: QueryLimit,
includeDisabled: boolean = false
) {
const disabledFilter = !includeDisabled
? {
suspended: false,
disabled: false,
}
: {};
const query = ids ? this.table.getAll(...ids) : this.table;
let filteredQuery = query
.filter((doc: any) => {
@ -109,7 +124,7 @@ export class UsersModel extends Model {
}
return true;
})
.filter(filter);
.filter({ ...filter, ...disabledFilter });
if (limit?.skip) {
filteredQuery = filteredQuery.skip(limit.skip);
@ -117,8 +132,25 @@ export class UsersModel extends Model {
if (limit?.limit) {
filteredQuery = filteredQuery.limit(limit.limit);
}
return filteredQuery;
}
async getUsers<T = DbUser>(
filter: UsersFilter,
limit?: QueryLimit,
includeDisabled: boolean = false
): Promise<T[]> {
const filteredQuery = this.userFilter(filter, limit, includeDisabled);
return (await filteredQuery.coerceTo("array").run(this.conn)) as T[];
}
return (await filteredQuery.coerceTo("array").run(this.conn)) as DbUser[];
async getUserCount(
filter: UsersFilter,
limit?: QueryLimit,
includeDisabled: boolean = false
): Promise<number> {
const filteredQuery = this.userFilter(filter, limit, includeDisabled);
return (await filteredQuery.count().run(this.conn)) as number;
}
async getUsersSafe(filter: UsersFilter = {}): Promise<User[]> {
@ -175,4 +207,14 @@ export class UsersModel extends Model {
return true;
}
async bulkUpdate(ids: string[], input: Partial<DbUser>) {
const result = await this.table
.getAll(...ids)
.update(input)
.run(this.conn);
if (result.errors) {
throw new ServerError("Error deleting users");
}
return true;
}
}

8
src/server/db/models/types.d.ts

@ -9,13 +9,17 @@ import {
} from "miracle-tv-shared/graphql";
export type DbUser = {
channels: [string];
roles: [string];
channels: string[];
roles: string[];
avatar: string;
header: string;
streamThumbnail: string;
email: string;
password: string;
silenced?: boolean;
suspended?: boolean;
loginDisabled?: boolean;
deleted?: boolean;
} & Omit<User, "channels" | "roles" | "avatar" | "header" | "streamThumbnail">;
export type DbUserSettings = {

24
src/server/graphql/errors/auth.ts

@ -8,6 +8,30 @@ export class InputErrorLogin extends ApolloError {
}
}
export class DeletedErrorLogin extends ApolloError {
constructor() {
super("Your account has been deleted", "E_LOGIN");
Object.defineProperty(this, "code", { value: "E_LOGIN" });
}
}
export class SuspendedErrorLogin extends ApolloError {
constructor() {
super("Your account has been suspended", "E_LOGIN");
Object.defineProperty(this, "code", { value: "E_LOGIN" });
}
}
export class DisabledErrorLogin extends ApolloError {
constructor() {
super("Login for your account has been disabled", "E_LOGIN");
Object.defineProperty(this, "code", { value: "E_LOGIN" });
}
}
export class AuthenticationError extends ApolloError {
constructor() {
super("Unauthenticated", "E_AUTHNETICATED");

13
src/server/graphql/index.ts

@ -86,6 +86,11 @@ import {
subscribeMutaiton,
unsubscribeMutation,
} from "./mutations/subscriptions";
import {
fullUserEntityResolver,
fullUserResolvers,
} from "./resolvers/full-users";
import { fullUserMutations } from "./mutations/full-users";
const schemaString = glob
.sync(path.resolve(__dirname, "./**/*.graphql"))
@ -127,6 +132,7 @@ let executableSchema = makeExecutableSchema({
selfSubscribedUsers: selfSubscribedUsersResolver,
test: userTestQueryResolver,
subscription: subsciptionByIdResolver,
...fullUserResolvers,
...fileResolvers,
},
Mutation: {
@ -148,8 +154,10 @@ let executableSchema = makeExecutableSchema({
revokeSelfSessions: revokeSelfSessionsMutation,
subscribe: subscribeMutaiton,
unsubscribe: unsubscribeMutation,
...fullUserMutations,
},
User: userResolver,
FullUser: fullUserEntityResolver,
UserSettings: settingsResolver,
Session: sessionResolver,
Channel: channelResolver,
@ -205,6 +213,9 @@ export const graphqlEndpoint = new ApolloServer({
? ((await db.users.getUserById(session?.user)) as DbUser | null)
: null;
const isUserInvalid =
user?.loginDisabled || user?.suspended || user?.deleted;
const allRoles = await db.roles.list();
const userRoles =
user?.roles?.map((role) =>
@ -213,7 +224,7 @@ export const graphqlEndpoint = new ApolloServer({
return {
db,
session,
user,
user: !isUserInvalid ? user : null,
userRoles,
} as ResolverContext;
},

77
src/server/graphql/mutations/full-users/index.ts

@ -0,0 +1,77 @@
import { ResolverContext } from "miracle-tv-server/types/resolver";
import { MutationResolvers } from "miracle-tv-shared/graphql";
export const fullUserMutations: MutationResolvers<ResolverContext> = {
async updateFullUser(_, { input: { id, ...input } }, { db: { users } }) {
await users.bulkUpdate([id], input);
return await users.getUserById(id);
},
// Delete / Restore users
async deleteFullUser(_, { id }, { db: { users } }) {
await users.bulkUpdate([id], { deleted: true });
return await users.getUserById(id);
},
async deleteFullUsers(_, { ids }, { db: { users } }) {
await users.bulkUpdate(ids, { deleted: true });
return (await users.getUsers({ ids }, undefined, true)) as any;
},
async restoreFullUser(_, { id }, { db: { users } }) {
await users.bulkUpdate([id], { deleted: false });
return await users.getUserById(id);
},
async restoreFullUsers(_, { ids }, { db: { users } }) {
await users.bulkUpdate(ids, { deleted: false });
return (await users.getUsers({ ids }, undefined, true)) as any;
},
// Suspend / Unsuspend users
async suspendFullUser(_, { id }, { db: { users } }) {
await users.bulkUpdate([id], { suspended: true });
return await users.getUserById(id);
},
async suspendFullUsers(_, { ids }, { db: { users } }) {
await users.bulkUpdate(ids, { suspended: true });
return (await users.getUsers({ ids }, undefined, true)) as any;
},
async unsuspendFullUser(_, { id }, { db: { users } }) {
await users.bulkUpdate([id], { suspended: false });
return await users.getUserById(id);
},
async unsuspendFullUsers(_, { ids }, { db: { users } }) {
await users.bulkUpdate(ids, { suspended: false });
return (await users.getUsers({ ids }, undefined, true)) as any;
},
// Disable / Enable login
async disableFullUserLogin(_, { id }, { db: { users } }) {
await users.bulkUpdate([id], { loginDisabled: true });
return await users.getUserById(id);
},
async disableFullUsersLogin(_, { ids }, { db: { users } }) {
await users.bulkUpdate(ids, { loginDisabled: true });
return (await users.getUsers({ ids }, undefined, true)) as any;
},
async enableFullUserLogin(_, { id }, { db: { users } }) {
await users.bulkUpdate([id], { loginDisabled: false });
return await users.getUserById(id);
},
async enableFullUsersLogin(_, { ids }, { db: { users } }) {
await users.bulkUpdate(ids, { loginDisabled: false });
return (await users.getUsers({ ids }, undefined, true)) as any;
},
// Silence / Unsilence login
async silenceFullUser(_, { id }, { db: { users } }) {
await users.bulkUpdate([id], { silenced: true });
return await users.getUserById(id);
},
async silenceFullUsers(_, { ids }, { db: { users } }) {
await users.bulkUpdate(ids, { silenced: true });
return (await users.getUsers({ ids }, undefined, true)) as any;
},
async unsilenceFullUser(_, { id }, { db: { users } }) {
await users.bulkUpdate([id], { silenced: false });
return await users.getUserById(id);
},
async unsilenceFullUsers(_, { ids }, { db: { users } }) {
await users.bulkUpdate(ids, { silenced: false });
return (await users.getUsers({ ids }, undefined, true)) as any;
},
};

17
src/server/graphql/mutations/users/auth.ts

@ -3,16 +3,29 @@ import { ResolverContext } from "miracle-tv-server/types/resolver";
import { head } from "ramda";
import {
AuthorizationError,
DeletedErrorLogin,
DisabledErrorLogin,
InputErrorLogin,
SuspendedErrorLogin,
} from "miracle-tv-server/graphql/errors/auth";
import { compare } from "bcrypt";
import { DbUser } from "miracle-tv-server/db/models/types";
export const signInMutation: MutationResolvers<ResolverContext>["signIn"] =
async (_, { input: { username, password } }, { db: { users, sessions } }) => {
const userList = await users.getUsers({ username });
const userList = await users.getUsers({ username }, undefined, true);
const user: DbUser = head<DbUser>(userList);
if (await compare(password, user?.password || "")) {
const isPasswordValid = await compare(password, user?.password || "");
if (user?.loginDisabled) {
throw new DisabledErrorLogin();
}
if (user?.suspended) {
throw new SuspendedErrorLogin();
}
if (user?.deleted) {
throw new DeletedErrorLogin();
}
if (isPasswordValid) {
return await sessions.createSession(user?.id!);
}
throw new InputErrorLogin();

3
src/server/graphql/mutations/users/index.ts

@ -33,9 +33,6 @@ export const userMutations: UserMutationResolvers = {
}
return userSettings.updateSettings(input, user.id);
},
async updateUser(_, { input }, { db: { users } }) {
return users.updateUser(input) as any;
},
async updateSelfAccount(_, { input }, { db: { users }, user }) {
return users.updateUserAccount({
id: user.id,

51
src/server/graphql/resolvers/full-users/index.ts

@ -0,0 +1,51 @@
import { ResolverContext } from "miracle-tv-server/types/resolver";
import {
FullUser,
FullUserResolvers,
QueryResolvers,
Role,
} from "miracle-tv-shared/graphql";
import { fileResolver } from "../file";
export const fullUserResolvers: QueryResolvers<ResolverContext> = {
async fullUsers(_, { filter, limit }, { db: { users } }) {
return users.getUsers<FullUser>(filter, limit, true);
},
async fullUserCount(_, { filter }, { db: { users } }) {
return users.getUserCount(filter, undefined, true);
},
async fullUser(_, { id }, { db: { users } }) {
return users.getUserById(id);
},
};
export const fullUserEntityResolver: FullUserResolvers<ResolverContext> = {
id: (user) => user.id,
username: (user) => user.username,
email: (user) => user.email,
channels: async (user, _, { db: { channels } }) => {
return await channels.getChannels({ userId: user.id! });
},
roles: async (user, _, { db: { roles } }) => {
const rolesList = await roles.getAll(