client/server: admin page improvements; add deleting users and changing roles

This commit is contained in:
Max Leiter 2022-04-12 21:14:10 -07:00
parent 7d5afbc682
commit 7d08570915
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
13 changed files with 511 additions and 187 deletions

View file

@ -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<Post[]>(
"/server-api/admin/posts",
adminFetcher
)
const { data: users, error: usersError } = useSWR<User[]>(
"/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 (
<div className={styles.adminWrapper}>
<Text h2>Administration</Text>
<Fieldset>
<Fieldset.Title>Users</Fieldset.Title>
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{usersError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
{users && (
<table>
<thead>
<tr>
<th>Username</th>
<th>Posts</th>
<th>Created</th>
<th>Role</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.posts?.length}</td>
<td>
{new Date(user.createdAt).toLocaleDateString()}{" "}
{new Date(user.createdAt).toLocaleTimeString()}
</td>
<td>{user.role}</td>
</tr>
))}
</tbody>
</table>
)}
</Fieldset>
<UserTable />
<Spacer height={1} />
<Fieldset>
<Fieldset.Title>Posts</Fieldset.Title>
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
{posts && (
<table>
<thead>
<tr>
<th>Title</th>
<th>Visibility</th>
<th>Created</th>
<th>Author</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{posts?.map((post) => (
<tr key={post.id}>
<td>
<PostModal id={post.id} />
</td>
<td>{post.visibility}</td>
<td>
{new Date(post.createdAt).toLocaleDateString()}{" "}
{new Date(post.createdAt).toLocaleTimeString()}
</td>
<td>
{post.users?.length ? (
post.users[0].username
) : (
<i>Deleted</i>
)}
</td>
<td>
{postSizes[post.id] ? `${postSizes[post.id]} MB` : ""}
</td>
</tr>
))}
</tbody>
</table>
)}
{Object.keys(postSizes).length && (
<div style={{ float: "right" }}>
<Text>
Total size:{" "}
{Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB
</Text>
</div>
)}
</Fieldset>
<PostTable />
</div>
)
}

View file

@ -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<Post[]>()
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 (
<Popover
content={
<div
style={{
width: 100,
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
{/* <Link href="#" onClick={() => deletePost(post.id)}>Delete post</Link> */}
</div>
}
hideArrow
>
<Button iconRight={<MoreVertical />} auto></Button>
</Popover>
)
}
}
]
return (
<Fieldset>
<Fieldset.Title>Posts</Fieldset.Title>
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{posts && <Table columns={tableColumns} data={tablePosts} />}
</Fieldset>
)
}
export default PostTable

View file

@ -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<User[]>()
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 (
<Popover
content={
<div
style={{
width: 100,
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
<Link href="#" onClick={() => toggleRole(user.id, user.role)}>
{user.role === "admin" ? "Change role" : "Make admin"}
</Link>
<Link href="#" onClick={() => deleteUser(user.id)}>
Delete user
</Link>
</div>
}
hideArrow
>
<Button iconRight={<MoreVertical />} auto></Button>
</Popover>
)
}
}
]
return (
<Fieldset>
<Fieldset.Title>Users</Fieldset.Title>
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{users && <Table columns={usernameColumns} data={tableUsers} />}
</Fieldset>
)
}
export default UserTable

View file

@ -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) {

View file

@ -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 (
<PasswordModal
creating={false}
onClose={onClose}
onSubmit={onSubmit}
isOpen={isPasswordModalOpen}
/>
)
return (
<PasswordModal
creating={false}
onClose={onClose}
onSubmit={onSubmit}
isOpen={isPasswordModalOpen}
/>
)
}
export default PasswordModalPage
export default PasswordModalPage

4
client/lib/byte-to-mb.ts Normal file
View file

@ -0,0 +1,4 @@
const byteToMB = (bytes: number) =>
Math.round((bytes / 1024 / 1024) * 100) / 100
export default byteToMB

View file

@ -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",

View file

@ -19,7 +19,8 @@ class MyDocument extends Document {
{initialProps.styles}
{styles}
</>
) as any
) as // TODO: Investigate typescript
any
}
}

View file

@ -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))
}
}

View file

@ -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
}

View file

@ -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"

View file

@ -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({

View file

@ -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,