diff --git a/client/components/admin/index.tsx b/client/components/admin/index.tsx index be14adf1..0cda083e 100644 --- a/client/components/admin/index.tsx +++ b/client/components/admin/index.tsx @@ -1,130 +1,35 @@ -import { Text, Fieldset, Spacer, Link } from "@geist-ui/core" +import { Text, Fieldset, Spacer } from "@geist-ui/core" import { Post, User } from "@lib/types" import Cookies from "js-cookie" -import { useEffect, useState } from "react" -import useSWR from "swr" +import { useEffect, useMemo, useState } from "react" import styles from "./admin.module.css" import PostModal from "./post-modal-link" +import PostTable from "./post-table" +import UserTable from "./user-table" -export const adminFetcher = (url: string) => - fetch(url, { - method: "GET", +export const adminFetcher = async ( + url: string, + options?: { + method?: string + body?: any + } +) => + fetch("/server-api/admin" + url, { + method: options?.method || "GET", headers: { "Content-Type": "application/json", Authorization: `Bearer ${Cookies.get("drift-token")}` - } - }).then((res) => res.json()) + }, + body: options?.body && JSON.stringify(options.body) + }) const Admin = () => { - const { data: posts, error: postsError } = useSWR( - "/server-api/admin/posts", - adminFetcher - ) - const { data: users, error: usersError } = useSWR( - "/server-api/admin/users", - adminFetcher - ) - const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({}) - const byteToMB = (bytes: number) => - Math.round((bytes / 1024 / 1024) * 100) / 100 - useEffect(() => { - if (posts) { - // sum the sizes of each file per post - const sizes = posts.reduce((acc, post) => { - const size = - post.files?.reduce((acc, file) => acc + file.html.length, 0) || 0 - return { ...acc, [post.id]: byteToMB(size) } - }, {}) - setPostSizes(sizes) - } - }, [posts]) - return (
Administration -
- Users - {users && {users.length} users} - {!users && Loading...} - {usersError && An error occured} - {users && ( - - - - - - - - - - - {users?.map((user) => ( - - - - - - - ))} - -
UsernamePostsCreatedRole
{user.username}{user.posts?.length} - {new Date(user.createdAt).toLocaleDateString()}{" "} - {new Date(user.createdAt).toLocaleTimeString()} - {user.role}
- )} -
+ -
- Posts - {posts && {posts.length} posts} - {!posts && Loading...} - {postsError && An error occured} - {posts && ( - - - - - - - - - - - - {posts?.map((post) => ( - - - - - - - - ))} - -
TitleVisibilityCreatedAuthorSize
- - {post.visibility} - {new Date(post.createdAt).toLocaleDateString()}{" "} - {new Date(post.createdAt).toLocaleTimeString()} - - {post.users?.length ? ( - post.users[0].username - ) : ( - Deleted - )} - - {postSizes[post.id] ? `${postSizes[post.id]} MB` : ""} -
- )} - {Object.keys(postSizes).length && ( -
- - Total size:{" "} - {Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB - -
- )} -
+
) } diff --git a/client/components/admin/post-table.tsx b/client/components/admin/post-table.tsx new file mode 100644 index 00000000..3019328c --- /dev/null +++ b/client/components/admin/post-table.tsx @@ -0,0 +1,139 @@ +import { Button, Fieldset, Link, Popover, useToasts } from "@geist-ui/core" +import MoreVertical from "@geist-ui/icons/moreVertical" +import { Post } from "@lib/types" +import Cookies from "js-cookie" +import { useEffect, useMemo, useState } from "react" +import { adminFetcher } from "." +import Table from "rc-table" +import byteToMB from "@lib/byte-to-mb" + +const PostTable = () => { + const [posts, setPosts] = useState() + const { setToast } = useToasts() + + useEffect(() => { + const fetchPosts = async () => { + const res = await adminFetcher("/posts") + const data = await res.json() + setPosts(data) + } + fetchPosts() + }, []) + + const tablePosts = useMemo( + () => + posts?.map((post) => { + return { + id: post.id, + title: post.title, + files: post.files?.length || 0, + createdAt: `${new Date( + post.createdAt + ).toLocaleDateString()} ${new Date( + post.createdAt + ).toLocaleTimeString()}`, + visibility: post.visibility, + size: post.files + ? byteToMB( + post.files.reduce((acc, file) => acc + file.html.length, 0) + ) + : 0, + actions: "" + } + }), + [posts] + ) + + // const deletePost = async (id: number) => { + // const confirm = window.confirm("Are you sure you want to delete this post?") + // if (!confirm) return + // const res = await adminFetcher(`/posts/${id}`, { + // method: "DELETE", + // }) + + // const json = await res.json() + + // if (res.status === 200) { + // setToast({ + // text: "Post deleted", + // type: "success" + // }) + // } else { + // setToast({ + // text: json.error || "Something went wrong", + // type: "error" + // }) + // } + // } + + const tableColumns = [ + { + title: "Title", + dataIndex: "title", + key: "title", + width: 50 + }, + { + title: "Files", + dataIndex: "files", + key: "files", + width: 10 + }, + { + title: "Created", + dataIndex: "createdAt", + key: "createdAt", + width: 100 + }, + { + title: "Visibility", + dataIndex: "visibility", + key: "visibility", + width: 50 + }, + { + title: "Size (MB)", + dataIndex: "size", + key: "size", + width: 10 + }, + { + title: "Actions", + dataIndex: "", + key: "actions", + width: 50, + render(post: any) { + return ( + + {/* deletePost(post.id)}>Delete post */} + + } + hideArrow + > + + + ) + } + } + ] + + return ( +
+ Posts + {posts && {posts.length} posts} + {!posts && Loading...} + {posts && } + + ) +} + +export default PostTable diff --git a/client/components/admin/user-table.tsx b/client/components/admin/user-table.tsx new file mode 100644 index 00000000..427cb4d3 --- /dev/null +++ b/client/components/admin/user-table.tsx @@ -0,0 +1,165 @@ +import { Button, Fieldset, Link, Popover, useToasts } from "@geist-ui/core" +import MoreVertical from "@geist-ui/icons/moreVertical" +import { User } from "@lib/types" +import Cookies from "js-cookie" +import { useEffect, useMemo, useState } from "react" +import { adminFetcher } from "." +import Table from "rc-table" + +const UserTable = () => { + const [users, setUsers] = useState() + const { setToast } = useToasts() + + useEffect(() => { + const fetchUsers = async () => { + const res = await adminFetcher("/users") + const data = await res.json() + setUsers(data) + } + fetchUsers() + }, []) + + const toggleRole = async (id: number, role: "admin" | "user") => { + const res = await adminFetcher("/users/toggle-role", { + method: "POST", + body: { id, role } + }) + + const json = await res.json() + + if (res.status === 200) { + setToast({ + text: "Role updated", + type: "success" + }) + + // TODO: swr should handle updating this + const newUsers = users?.map((user) => { + if (user.id === id.toString()) { + return { + ...user, + role: role === "admin" ? "user" : ("admin" as User["role"]) + } + } + return user + }) + setUsers(newUsers) + } else { + setToast({ + text: json.error || "Something went wrong", + type: "error" + }) + } + } + + const deleteUser = async (id: number) => { + const confirm = window.confirm("Are you sure you want to delete this user?") + if (!confirm) return + const res = await adminFetcher(`/users/${id}`, { + method: "DELETE" + }) + + const json = await res.json() + + if (res.status === 200) { + setToast({ + text: "User deleted", + type: "success" + }) + } else { + setToast({ + text: json.error || "Something went wrong", + type: "error" + }) + } + } + + const tableUsers = useMemo( + () => + users?.map((user) => { + return { + id: user.id, + username: user.username, + posts: user.posts?.length || 0, + createdAt: `${new Date( + user.createdAt + ).toLocaleDateString()} ${new Date( + user.createdAt + ).toLocaleTimeString()}`, + role: user.role, + actions: "" + } + }), + [users] + ) + + const usernameColumns = [ + { + title: "Username", + dataIndex: "username", + key: "username", + width: 50 + }, + { + title: "Posts", + dataIndex: "posts", + key: "posts", + width: 10 + }, + { + title: "Created", + dataIndex: "createdAt", + key: "createdAt", + width: 100 + }, + { + title: "Role", + dataIndex: "role", + key: "role", + width: 50 + }, + { + title: "Actions", + dataIndex: "", + key: "actions", + width: 50, + render(user: any) { + return ( + + toggleRole(user.id, user.role)}> + {user.role === "admin" ? "Change role" : "Make admin"} + + deleteUser(user.id)}> + Delete user + + + } + hideArrow + > + + + ) + } + } + ] + + return ( +
+ Users + {users && {users.length} users} + {!users && Loading...} + {users &&
} + + ) +} + +export default UserTable diff --git a/client/components/new-post/drag-and-drop/index.tsx b/client/components/new-post/drag-and-drop/index.tsx index c68908df..89cfeb22 100644 --- a/client/components/new-post/drag-and-drop/index.tsx +++ b/client/components/new-post/drag-and-drop/index.tsx @@ -9,6 +9,7 @@ import { allowedFileNames, allowedFileExtensions } from "@lib/constants" +import byteToMB from "@lib/byte-to-mb" function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) { const { palette } = useTheme() @@ -40,9 +41,6 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) { } const validator = (file: File) => { - const byteToMB = (bytes: number) => - Math.round((bytes / 1024 / 1024) * 100) / 100 - // TODO: make this configurable const maxFileSize = 50000000 if (file.size > maxFileSize) { diff --git a/client/components/post-page/password-modal-wrapper.tsx b/client/components/post-page/password-modal-wrapper.tsx index 3a384b3f..4ece7d37 100644 --- a/client/components/post-page/password-modal-wrapper.tsx +++ b/client/components/post-page/password-modal-wrapper.tsx @@ -5,60 +5,60 @@ import { useRouter } from "next/router" import { useState } from "react" type Props = { - setPost: (post: Post) => void + setPost: (post: Post) => void } const PasswordModalPage = ({ setPost }: Props) => { - const router = useRouter() - const { setToast } = useToasts() - const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true) + const router = useRouter() + const { setToast } = useToasts() + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true) - const onSubmit = async (password: string) => { - const res = await fetch( - `/server-api/posts/authenticate?id=${router.query.id}&password=${password}`, - { - method: "GET", - headers: { - "Content-Type": "application/json" - } - } - ) + const onSubmit = async (password: string) => { + const res = await fetch( + `/server-api/posts/authenticate?id=${router.query.id}&password=${password}`, + { + method: "GET", + headers: { + "Content-Type": "application/json" + } + } + ) - if (!res.ok) { - setToast({ - type: "error", - text: "Wrong password" - }) - return - } + if (!res.ok) { + setToast({ + type: "error", + text: "Wrong password" + }) + return + } - const data = await res.json() - if (data) { - if (data.error) { - setToast({ - text: data.error, - type: "error" - }) - } else { - setIsPasswordModalOpen(false) - setPost(data) - } - } - } + const data = await res.json() + if (data) { + if (data.error) { + setToast({ + text: data.error, + type: "error" + }) + } else { + setIsPasswordModalOpen(false) + setPost(data) + } + } + } - const onClose = () => { - setIsPasswordModalOpen(false) - router.push("/") - } + const onClose = () => { + setIsPasswordModalOpen(false) + router.push("/") + } - return ( - - ) + return ( + + ) } -export default PasswordModalPage \ No newline at end of file +export default PasswordModalPage diff --git a/client/lib/byte-to-mb.ts b/client/lib/byte-to-mb.ts new file mode 100644 index 00000000..aae51ab5 --- /dev/null +++ b/client/lib/byte-to-mb.ts @@ -0,0 +1,4 @@ +const byteToMB = (bytes: number) => + Math.round((bytes / 1024 / 1024) * 100) / 100 + +export default byteToMB diff --git a/client/package.json b/client/package.json index 0452e3a3..611c16c4 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "js-cookie": "3.0.1", "next": "12.1.5", "next-themes": "0.1.1", + "rc-table": "^7.24.1", "react": "18.0.0", "react-datepicker": "4.7.0", "react-dom": "18.0.0", diff --git a/client/pages/_document.tsx b/client/pages/_document.tsx index e3951bdb..b4c6a15c 100644 --- a/client/pages/_document.tsx +++ b/client/pages/_document.tsx @@ -19,7 +19,8 @@ class MyDocument extends Document { {initialProps.styles} {styles} - ) as any + ) as // TODO: Investigate typescript + any } } diff --git a/client/pages/_middleware.tsx b/client/pages/_middleware.tsx index e4904467..cba474aa 100644 --- a/client/pages/_middleware.tsx +++ b/client/pages/_middleware.tsx @@ -51,7 +51,10 @@ export function middleware(req: NextRequest, event: NextFetchEvent) { } if (pathname.includes("/protected/") || pathname.includes("/private/")) { - const urlWithoutVisibility = pathname.replace("/protected/", "/").replace("/private/", "/").substring(1) + const urlWithoutVisibility = pathname + .replace("/protected/", "/") + .replace("/private/", "/") + .substring(1) return NextResponse.redirect(getURL(urlWithoutVisibility)) } } diff --git a/client/pages/post/[id].tsx b/client/pages/post/[id].tsx index 659044e0..4366f4e4 100644 --- a/client/pages/post/[id].tsx +++ b/client/pages/post/[id].tsx @@ -45,8 +45,10 @@ export const getServerSideProps: GetServerSideProps = async ({ } } - const json = await post.json() as Post - const isAuthor = json.users?.find(user => user.id === req.cookies["drift-userid"]) + const json = (await post.json()) as Post + const isAuthor = json.users?.find( + (user) => user.id === req.cookies["drift-userid"] + ) if (json.visibility === "public" || json.visibility === "unlisted") { const sMaxAge = 60 * 60 * 12 // half a day @@ -60,7 +62,7 @@ export const getServerSideProps: GetServerSideProps = async ({ post: { id: json.id, visibility: json.visibility, - expiresAt: json.expiresAt, + expiresAt: json.expiresAt }, isProtected: true } diff --git a/client/yarn.lock b/client/yarn.lock index aed5672d..d5e78c2c 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -15,7 +15,7 @@ core-js-pure "^3.20.2" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.2", "@babel/runtime@^7.16.3", "@babel/runtime@^7.16.7": +"@babel/runtime@^7.10.1", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.16.7": version "7.17.9" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72" integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg== @@ -552,7 +552,7 @@ chalk@^4.0.0, chalk@^4.1.0: optionalDependencies: fsevents "~2.3.2" -classnames@^2.2.6: +classnames@^2.2.1, classnames@^2.2.5, classnames@^2.2.6: version "2.3.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== @@ -2446,6 +2446,36 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +rc-resize-observer@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/rc-resize-observer/-/rc-resize-observer-1.2.0.tgz#9f46052f81cdf03498be35144cb7c53fd282c4c7" + integrity sha512-6W+UzT3PyDM0wVCEHfoW3qTHPTvbdSgiA43buiy8PzmeMnfgnDeb9NjdimMXMl3/TcrvvWl5RRVdp+NqcR47pQ== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.1" + rc-util "^5.15.0" + resize-observer-polyfill "^1.5.1" + +rc-table@^7.24.1: + version "7.24.1" + resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.24.1.tgz#15ecabc9d69f8300b988caa52986e3b215150f2b" + integrity sha512-DRWpv5z5pmOaTmy5GqWoskeV1thaOu5HuD+2f61b/CkbBqlgJR3cygc5R/Qvd2uVW6pHU0lYulhmz0VLVFm+rw== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.5" + rc-resize-observer "^1.1.0" + rc-util "^5.14.0" + shallowequal "^1.1.0" + +rc-util@^5.14.0, rc-util@^5.15.0: + version "5.20.1" + resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.20.1.tgz#323590df56175f60b1a67d2ba76f04c3c2cb84cd" + integrity sha512-2IEyErPAYl0Up5gBu71e8IkOs+/SL9XRUvnGhtsr7IHlXLx2OsbQKTDpWacJbzLCmNcgJylDGj1kiklx+zagRA== + dependencies: + "@babel/runtime" "^7.12.5" + react-is "^16.12.0" + shallowequal "^1.1.0" + rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" @@ -2490,7 +2520,7 @@ react-fast-compare@^3.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== -react-is@^16.13.1: +react-is@^16.12.0, react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -2590,6 +2620,11 @@ reserved-words@^0.1.2: resolved "https://registry.yarnpkg.com/reserved-words/-/reserved-words-0.1.2.tgz#00a0940f98cd501aeaaac316411d9adc52b31ab1" integrity sha1-AKCUD5jNUBrqqsMWQR2a3FKzGrE= +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-dependency-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-dependency-path/-/resolve-dependency-path-2.0.0.tgz#11700e340717b865d216c66cabeb4a2a3c696736" @@ -2709,6 +2744,11 @@ semver@^7.3.5: dependencies: lru-cache "^6.0.0" +shallowequal@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 559e3e01..89716be4 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,8 +1,9 @@ -import isAdmin from "@lib/middleware/is-admin" +import isAdmin, { UserJwtRequest } from "@lib/middleware/is-admin" import { Post } from "@lib/models/Post" import { User } from "@lib/models/User" import { File } from "@lib/models/File" import { Router } from "express" +import { celebrate, Joi } from "celebrate" export const admin = Router() @@ -35,6 +36,81 @@ admin.get("/users", async (req, res, next) => { } }) +admin.post( + "/users/toggle-role", + celebrate({ + body: { + id: Joi.string().required(), + role: Joi.string().required().allow("user", "admin") + } + }), + async (req: UserJwtRequest, res, next) => { + try { + const { id, role } = req.body + if (req.user?.id === id) { + return res.status(400).json({ + error: "You can't change your own role" + }) + } + + const user = await User.findByPk(id) + if (!user) { + return res.status(404).json({ + error: "User not found" + }) + } + + await user.update({ + role + }) + + await user.save() + + res.json({ + success: true + }) + } catch (e) { + next(e) + } + } +) + +admin.delete("/users/:id", async (req, res, next) => { + try { + const user = await User.findByPk(req.params.id) + if (!user) { + return res.status(404).json({ + error: "User not found" + }) + } + await user.destroy() + + res.json({ + success: true + }) + } catch (e) { + next(e) + } +}) + +// admin.delete("/posts/:id", async (req, res, next) => { +// try { +// const post = await Post.findByPk(req.params.id) +// if (!post) { +// return res.status(404).json({ +// error: "Post not found" +// }) +// } +// await post.destroy() + +// res.json({ +// success: true +// }) +// } catch (e) { +// next(e) +// } +// }) + admin.get("/posts", async (req, res, next) => { try { const posts = await Post.findAll({ diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index 19bc0776..b2e34c89 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -243,14 +243,7 @@ const fullPostSequelizeOptions = { { model: File, as: "files", - attributes: [ - "id", - "title", - "content", - "sha", - "createdAt", - "updatedAt" - ] + attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"] }, { model: User, @@ -270,11 +263,12 @@ const fullPostSequelizeOptions = { "createdAt", "updatedAt", "deletedAt", - "expiresAt", + "expiresAt" ] } -posts.get("/authenticate", +posts.get( + "/authenticate", celebrate({ query: { id: Joi.string().required(), @@ -286,10 +280,7 @@ posts.get("/authenticate", const post = await Post.findByPk(id?.toString(), { ...fullPostSequelizeOptions, - attributes: [ - ...fullPostSequelizeOptions.attributes, - "password" - ] + attributes: [...fullPostSequelizeOptions.attributes, "password"] }) const hash = crypto @@ -306,7 +297,6 @@ posts.get("/authenticate", } ) - posts.get( "/:id", secretKey,