Add deleting users to admin, refactor auth

This commit is contained in:
Max Leiter 2022-12-17 23:09:47 -08:00
parent 65cf59e96b
commit 447974a74a
20 changed files with 534 additions and 280 deletions

View file

@ -1,26 +1,80 @@
"use client" "use client"
import { useState } from "react" import { startTransition, useEffect, useRef, useState } from "react"
import styles from "./auth.module.css" import styles from "./auth.module.css"
import Link from "../../components/link" import Link from "../../components/link"
import { signIn } from "next-auth/react" import { getSession, signIn } from "next-auth/react"
import Input from "@components/input" import Input from "@components/input"
import Button from "@components/button" import Button from "@components/button"
import Note from "@components/note"
import { GitHub } from "react-feather" import { GitHub } from "react-feather"
import { useToasts } from "@components/toasts"
import { useRouter, useSearchParams } from "next/navigation"
import Note from "@components/note"
const Auth = ({ const Auth = ({
page, page,
requiresServerPassword requiresServerPassword,
isGithubEnabled
}: { }: {
page: "signup" | "signin" page: "signup" | "signin"
requiresServerPassword?: boolean requiresServerPassword?: boolean
isGithubEnabled?: boolean
}) => { }) => {
const [serverPassword, setServerPassword] = useState("") const [serverPassword, setServerPassword] = useState("")
const [errorMsg, setErrorMsg] = useState("") const { setToast } = useToasts()
const signingIn = page === "signin" const signingIn = page === "signin"
const router = useRouter()
const signText = signingIn ? "In" : "Up" const signText = signingIn ? "In" : "Up"
const [username, setUsername] = useState("") const [username, setUsername] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const queryParams = useSearchParams()
useEffect(() => {
if (queryParams.get("error")) {
setToast({
message: queryParams.get("error") as string,
type: "error"
})
}
}, [queryParams, setToast])
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const res = await signIn("credentials", {
username,
password,
registration_password: serverPassword,
redirect: false,
// callbackUrl: "/signin",
signingIn: signingIn
})
if (res?.error) {
setToast({
type: "error",
message: res.error
})
} else {
console.log("res", res)
startTransition(() => {
router.push("/new")
router.refresh()
})
}
}
const handleChangeUsername = (event: React.ChangeEvent<HTMLInputElement>) => {
setUsername(event.target.value)
}
const handleChangePassword = (event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value)
}
const handleChangeServerPassword = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setServerPassword(event.target.value)
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -28,16 +82,37 @@ const Auth = ({
<div className={styles.formContentSpace}> <div className={styles.formContentSpace}>
<h1>Sign {signText}</h1> <h1>Sign {signText}</h1>
</div> </div>
{/* <form onSubmit={handleSubmit}> */} <form onSubmit={handleSubmit}>
<form>
<div className={styles.formGroup}> <div className={styles.formGroup}>
{requiresServerPassword ? (
<>
{" "}
<Note type="info">
The server administrator has set a password for this server.
</Note>
<Input
type="password"
id="server-password"
value={serverPassword}
onChange={(event) =>
setServerPassword(event.currentTarget.value)
}
placeholder="Server Password"
required={true}
width="100%"
aria-label="Server Password"
/>
<hr style={{ width: "100%" }} />
</>
) : null}
<Input <Input
type="text" type="text"
id="username" id="username"
value={username} value={username}
onChange={(event) => setUsername(event.currentTarget.value)} onChange={handleChangeUsername}
placeholder="Username" placeholder="Username"
required required={true}
minLength={3} minLength={3}
width="100%" width="100%"
aria-label="Username" aria-label="Username"
@ -46,59 +121,37 @@ const Auth = ({
type="password" type="password"
id="password" id="password"
value={password} value={password}
onChange={(event) => setPassword(event.currentTarget.value)} onChange={handleChangePassword}
placeholder="Password" placeholder="Password"
required required={true}
minLength={6} minLength={6}
width="100%" width="100%"
aria-label="Password" aria-label="Password"
/> />
{requiresServerPassword && ( <Button width={"100%"} type="submit">
<Input
type="password"
id="server-password"
value={serverPassword}
onChange={(event) =>
setServerPassword(event.currentTarget.value)
}
placeholder="Server Password"
required
width="100%"
aria-label="Server Password"
/>
)}
<Button
width={"100%"}
type="submit"
onClick={(e) => {
e.preventDefault()
signIn("credentials", {
username,
password,
callbackUrl: "/"
})
}}
>
Sign {signText} Sign {signText}
</Button> </Button>
<hr style={{ width: "100%" }} /> {isGithubEnabled ? <hr style={{ width: "100%" }} /> : null}
<Button {isGithubEnabled ? (
type="submit" <Button
buttonType="primary" type="submit"
width="100%" buttonType="primary"
style={{ width="100%"
color: "var(--fg)" style={{
}} color: "var(--fg)"
iconLeft={<GitHub />} }}
onClick={(e) => { iconLeft={<GitHub />}
e.preventDefault() onClick={(e) => {
signIn("github", { e.preventDefault()
callbackUrl: "/" signIn("github", {
}) callbackUrl: "/",
}} registration_password: serverPassword
> })
Sign {signText.toLowerCase()} with GitHub }}
</Button> >
Sign {signText.toLowerCase()} with GitHub
</Button>
) : null}
</div> </div>
<div className={styles.formContentSpace}> <div className={styles.formContentSpace}>
{signingIn ? ( {signingIn ? (
@ -117,7 +170,6 @@ const Auth = ({
</p> </p>
)} )}
</div> </div>
{errorMsg && <Note type="error">{errorMsg}</Note>}
</form> </form>
</div> </div>
</div> </div>

View file

@ -1,5 +1,10 @@
import config from "@lib/config"
import Auth from "../components" import Auth from "../components"
export default function SignInPage() { export function isGithubEnabled() {
return <Auth page="signin" /> return config.github_client_id.length && config.github_client_secret.length ? true : false
}
export default function SignInPage() {
return <Auth page="signin" isGithubEnabled={isGithubEnabled()} />
} }

View file

@ -1,5 +1,6 @@
import Auth from "../components" import Auth from "../components"
import { getRequiresPasscode } from "pages/api/auth/requires-passcode" import { getRequiresPasscode } from "pages/api/auth/requires-passcode"
import { isGithubEnabled } from "../signin/page"
const getPasscode = async () => { const getPasscode = async () => {
return await getRequiresPasscode() return await getRequiresPasscode()
@ -7,5 +8,5 @@ const getPasscode = async () => {
export default async function SignUpPage() { export default async function SignUpPage() {
const requiresPasscode = await getPasscode() const requiresPasscode = await getPasscode()
return <Auth page="signup" requiresServerPassword={requiresPasscode} /> return <Auth page="signup" requiresServerPassword={requiresPasscode} isGithubEnabled={isGithubEnabled()} />
} }

View file

@ -187,7 +187,7 @@ const Post = ({
) )
if (session.status === "unauthenticated") { if (session.status === "unauthenticated") {
router.push("/login") router.push("/signin")
return null return null
} }

View file

@ -1,13 +0,0 @@
.table {
width: 100%;
display: block;
white-space: nowrap;
}
.table thead th {
font-weight: bold;
}
.table .id {
width: 10ch;
}

View file

@ -0,0 +1,17 @@
.table {
width: 100%;
display: block;
white-space: nowrap;
}
.table thead th {
font-weight: bold;
}
.id {
width: 130px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}

View file

@ -0,0 +1,138 @@
"use client"
import Button from "@components/button"
import ButtonDropdown from "@components/button-dropdown"
import { Spinner } from "@components/spinner"
import { useToasts } from "@components/toasts"
import { Post, User } from "@lib/server/prisma"
import { useState } from "react"
import styles from "./table.module.css"
export function UserTable({
users: initialUsers
}: {
users?: {
createdAt: string
posts?: Post[]
id: string
email: string | null
role: string | null
displayName: string | null
}[]
}) {
const { setToast } = useToasts()
const [users, setUsers] = useState<typeof initialUsers>(initialUsers)
const deleteUser = async (id: string) => {
try {
const res = await fetch("/api/admin?action=delete-user", {
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
userId: id
})
})
if (res.status === 200) {
setToast({
message: "User deleted",
type: "success"
})
setUsers(users?.filter((user) => user.id !== id))
}
} catch (err) {
console.error(err)
setToast({
message: "Error deleting user",
type: "error"
})
}
}
return (
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>User ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{!users ? (
<tr>
<td colSpan={4}>
<Spinner />
</td>
</tr>
) : null}
{users?.map((user) => (
<tr key={user.id}>
<td>{user.displayName ? user.displayName : "no name"}</td>
<td>{user.email}</td>
<td>{user.role}</td>
<td className={styles.id} title={user.id}>
{user.id}
</td>
<td>
<Button onClick={() => deleteUser(user.id)}>Delete</Button>
</td>
</tr>
))}
</tbody>
</table>
)
}
export function PostTable({
posts
}: {
posts?: {
createdAt: string
id: string
author?: User | null
title: string
visibility: string
}[]
}) {
return (
<table className={styles.table}>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Created</th>
<th>Visibility</th>
<th className={styles.id}>Post ID</th>
</tr>
</thead>
<tbody>
{!posts ? (
<tr>
<td colSpan={5}>
<Spinner />
</td>
</tr>
) : null}
{posts?.map((post) => (
<tr key={post.id}>
<td>
<a href={`/post/${post.id}`} target="_blank" rel="noreferrer">
{post.title}
</a>
</td>
<td>{"author" in post ? post.author?.name : "no author"}</td>
<td>{new Date(post.createdAt).toLocaleDateString()}</td>
<td>{post.visibility}</td>
<td>{post.id}</td>
</tr>
))}
</tbody>
</table>
)
}

View file

@ -1,4 +1,4 @@
import { PostTable, UserTable } from "./page" import { PostTable, UserTable } from "./components/tables"
export default function AdminLoading() { export default function AdminLoading() {
return ( return (

View file

@ -1,100 +1,55 @@
import { getAllPosts, getAllUsers } from "@lib/server/prisma"
import { Spinner } from "@components/spinner" import { Spinner } from "@components/spinner"
import styles from "./admin.module.css" import { getAllPosts, getAllUsers } from "@lib/server/prisma"
import { PostTable, UserTable } from "./components/tables"
export function UserTable({
users
}: {
users?: Awaited<ReturnType<typeof getAllUsers>>
}) {
return (
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>User ID</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr key={user.id}>
<td>{user.displayName ? user.displayName : "no name"}</td>
<td>{user.email}</td>
<td>{user.role}</td>
<td className={styles.id}>{user.id}</td>
</tr>
))}
{!users && (
<tr>
<td colSpan={4}>
<Spinner />
</td>
</tr>
)}
</tbody>
</table>
)
}
export function PostTable({
posts
}: {
posts?: Awaited<ReturnType<typeof getAllPosts>>
}) {
return (
<table className={styles.table}>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Created</th>
<th>Visibility</th>
<th className={styles.id}>Post ID</th>
</tr>
</thead>
<tbody>
{posts?.map((post) => (
<tr key={post.id}>
<td>
<a href={`/post/${post.id}`} target="_blank" rel="noreferrer">
{post.title}
</a>
</td>
<td>{"author" in post ? post.author?.name : "no author"}</td>
<td>{post.createdAt.toLocaleDateString()}</td>
<td>{post.visibility}</td>
<td>{post.id}</td>
</tr>
))}
{!posts && (
<tr>
<td colSpan={5}>
<Spinner />
</td>
</tr>
)}
</tbody>
</table>
)
}
export default async function AdminPage() { export default async function AdminPage() {
const usersPromise = getAllUsers() const usersPromise = getAllUsers({
select: {
id: true,
name: true,
createdAt: true
}
})
const postsPromise = getAllPosts({ const postsPromise = getAllPosts({
withAuthor: true select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
author: {
select: {
name: true
}
}
}
}) })
const [users, posts] = await Promise.all([usersPromise, postsPromise]) const [users, posts] = await Promise.all([usersPromise, postsPromise])
const serializedPosts = posts.map((post) => {
return {
...post,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
expiresAt: post.expiresAt?.toISOString(),
deletedAt: post.deletedAt?.toISOString()
}
})
const serializedUsers = users.map((user) => {
return {
...user,
createdAt: user.createdAt.toISOString()
}
})
return ( return (
<div> <div>
<h1>Admin</h1> <h1>Admin</h1>
<h2>Users</h2> <h2>Users</h2>
<UserTable users={users} /> <UserTable users={serializedUsers} />
<h2>Posts</h2> <h2>Posts</h2>
<PostTable posts={posts} /> <PostTable posts={serializedPosts} />
</div> </div>
) )
} }

View file

@ -34,6 +34,7 @@ type Tab = {
const Header = () => { const Header = () => {
const session = useSession() const session = useSession()
console.log("session", session)
const isSignedIn = session?.status === "authenticated" const isSignedIn = session?.status === "authenticated"
const isAdmin = session?.data?.user?.role === "admin" const isAdmin = session?.data?.user?.role === "admin"
const isLoading = session?.status === "loading" const isLoading = session?.status === "loading"

View file

@ -40,7 +40,7 @@ type InputProps = Omit<Props, "onChange" | "value" | "label" | "aria-label"> &
) )
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, className, width, height, labelClassName, ...props }, ref) => { ({ label, className, required, width, height, labelClassName, ...props }, ref) => {
return ( return (
<div <div
className={styles.wrapper} className={styles.wrapper}

View file

@ -13,9 +13,24 @@ const getWelcomeData = async () => {
export default async function Page() { export default async function Page() {
const { content, rendered, title } = await getWelcomeData() const { content, rendered, title } = await getWelcomeData()
const getPostsPromise = getAllPosts({ const getPostsPromise = getAllPosts({
where: { visibility: "public" }, select: {
include: { id: true,
files: true title: true,
createdAt: true,
author: {
select: {
name: true
}
}
},
where: {
deletedAt: null,
expiresAt: {
gt: new Date()
}
},
orderBy: {
createdAt: "desc"
} }
}) })

View file

@ -1,4 +1,3 @@
type Config = { type Config = {
is_production: boolean is_production: boolean
enable_admin: boolean enable_admin: boolean
@ -9,6 +8,7 @@ type Config = {
github_client_id: string github_client_id: string
github_client_secret: string github_client_secret: string
nextauth_secret: string nextauth_secret: string
credential_auth: boolean
} }
type EnvironmentValue = string | undefined type EnvironmentValue = string | undefined
@ -77,6 +77,9 @@ export const config = (env: Environment): Config => {
github_client_id: env.GITHUB_CLIENT_ID ?? "", github_client_id: env.GITHUB_CLIENT_ID ?? "",
github_client_secret: env.GITHUB_CLIENT_SECRET ?? "", github_client_secret: env.GITHUB_CLIENT_SECRET ?? "",
nextauth_secret: throwIfUndefined("NEXTAUTH_SECRET"), nextauth_secret: throwIfUndefined("NEXTAUTH_SECRET"),
credential_auth: stringToBoolean(
developmentDefault("CREDENTIAL_AUTH", "true")
)
} }
return config return config
} }

View file

@ -5,83 +5,140 @@ import CredentialsProvider from "next-auth/providers/credentials"
import { prisma } from "@lib/server/prisma" import { prisma } from "@lib/server/prisma"
import config from "@lib/config" import config from "@lib/config"
import * as crypto from "crypto" import * as crypto from "crypto"
import { Provider } from "next-auth/providers"
const providers: NextAuthOptions["providers"] = [ const credentialsOptions = () => {
GitHubProvider({ const options: Record<string, any> = {
clientId: config.github_client_id, username: {
clientSecret: config.github_client_secret label: "Username",
}), required: true,
CredentialsProvider({ type: "text"
name: "Credentials",
credentials: {
username: { label: "Username", type: "text", placeholder: "jsmith" },
password: { label: "Password", type: "password" }
}, },
async authorize(credentials) { password: {
if (!credentials) { label: "Password",
return null required: true,
} type: "password"
}
}
const user = await prisma.user.findUnique({ if (config.registration_password) {
where: { options["registration_password"] = {
username: credentials.username label: "Server Password",
}, type: "password",
select: { optional: true
id: true, }
username: true, }
displayName: true,
role: true, return options
password: true }
const providers = () => {
const providers = []
if (config.github_client_id && config.github_client_secret) {
providers.push(
GitHubProvider({
clientId: config.github_client_id,
clientSecret: config.github_client_secret
})
)
}
if (config.credential_auth) {
providers.push(
CredentialsProvider({
name: "Drift",
credentials: credentialsOptions(),
async authorize(credentials) {
if (!credentials || !credentials.username || !credentials.password) {
throw new Error("Missing credentials")
}
if (credentials.username.length < 3) {
throw new Error("Username must be at least 3 characters")
}
if (credentials.password.length < 3) {
throw new Error("Password must be at least 3 characters")
}
const user = await prisma.user.findUnique({
where: {
username: credentials.username
},
select: {
id: true,
username: true,
displayName: true,
role: true,
password: true
}
})
const hashedPassword = crypto
.createHash("sha256")
.update(credentials.password + config.nextauth_secret)
.digest("hex")
if (credentials.signingIn === "true") {
if (
user?.password &&
crypto.timingSafeEqual(
Buffer.from(user.password),
Buffer.from(hashedPassword)
)
) {
return user
} else {
throw new Error("Incorrect username or password")
}
} else {
if (config.registration_password) {
if (!credentials.registration_password) {
throw new Error("Missing registration password")
}
if (
credentials.registration_password !==
config.registration_password
) {
throw new Error("Incorrect registration password")
}
}
if (user) {
throw new Error("Username already taken")
}
const newUser = await prisma.user.create({
data: {
username: credentials.username,
displayName: credentials.username,
role: "user",
password: hashedPassword,
name: credentials.username
}
})
return newUser
}
} }
}) })
)
}
const hashedPassword = crypto return providers
.createHash("sha256") }
.update
(credentials
.password
+ config.nextauth_secret)
.digest("hex")
if (!user) {
const newUser = await prisma.user.create({
data: {
username: credentials.username,
displayName: credentials.username,
role: "user",
password: hashedPassword,
name: credentials.username,
}
})
return newUser
} else if (
user.password &&
crypto.timingSafeEqual(
Buffer.from(user.password),
Buffer.from(hashedPassword)
)
) {
return user
}
return null
}
})
]
export const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
// see https://github.com/prisma/prisma/issues/16117 / https://github.com/shadcn/taxonomy adapter: PrismaAdapter(prisma),
adapter: PrismaAdapter(prisma as any),
session: { session: {
strategy: "jwt" strategy: "jwt"
}, },
pages: { pages: {
signIn: "/signin" signIn: "/signin",
// TODO error: "/signin"
// error: "/auth/error",
}, },
providers, providers: providers(),
callbacks: { callbacks: {
async session({ token, session }) { async session({ token, session }) {
if (token) { if (token) {
@ -94,6 +151,7 @@ export const authOptions: NextAuthOptions = {
return session return session
}, },
async jwt({ token, user }) { async jwt({ token, user }) {
const dbUser = await prisma.user.findFirst({ const dbUser = await prisma.user.findFirst({
where: { where: {

View file

@ -57,7 +57,9 @@ const postWithFilesAndAuthor = Prisma.validator<Prisma.PostArgs>()({
export type ServerPostWithFiles = Prisma.PostGetPayload<typeof postWithFiles> export type ServerPostWithFiles = Prisma.PostGetPayload<typeof postWithFiles>
export type PostWithAuthor = Prisma.PostGetPayload<typeof postWithAuthor> export type PostWithAuthor = Prisma.PostGetPayload<typeof postWithAuthor>
export type ServerPostWithFilesAndAuthor = Prisma.PostGetPayload<typeof postWithFilesAndAuthor> export type ServerPostWithFilesAndAuthor = Prisma.PostGetPayload<
typeof postWithFilesAndAuthor
>
export type PostWithFiles = Omit<ServerPostWithFiles, "files"> & { export type PostWithFiles = Omit<ServerPostWithFiles, "files"> & {
files: (Omit<ServerPostWithFiles["files"][number], "content" | "html"> & { files: (Omit<ServerPostWithFiles["files"][number], "content" | "html"> & {
@ -70,7 +72,10 @@ export type PostWithFilesAndAuthor = Omit<
ServerPostWithFilesAndAuthor, ServerPostWithFilesAndAuthor,
"files" "files"
> & { > & {
files: (Omit<ServerPostWithFilesAndAuthor["files"][number], "content" | "html"> & { files: (Omit<
ServerPostWithFilesAndAuthor["files"][number],
"content" | "html"
> & {
content: string content: string
html: string html: string
})[] })[]
@ -201,40 +206,25 @@ export const getPostById = async (
return post return post
} }
export const getAllPosts = async ({ export const getAllPosts = async (
withFiles = false, options?: Prisma.PostFindManyArgs
withAuthor = false, ): Promise<Post[] | ServerPostWithFiles[] | ServerPostWithFilesAndAuthor[]> => {
take = 100, const posts = await prisma.post.findMany(options)
...rest return posts
}: {
withFiles?: boolean
withAuthor?: boolean
} & Prisma.PostFindManyArgs = {}): Promise<
Post[] | ServerPostWithFiles[] | ServerPostWithFilesAndAuthor[]
> => {
const posts = await prisma.post.findMany({
include: {
files: withFiles,
author: withAuthor
},
// TODO: optimize which to grab
take,
...rest
})
return posts as typeof withFiles extends true
? typeof withAuthor extends true
? PostWithFilesAndAuthor[]
: PostWithFiles[]
: Post[]
} }
export type UserWithPosts = User & { export const userWithPosts = Prisma.validator<Prisma.UserArgs>()({
posts: Post[] include: {
} posts: true
}
})
export const getAllUsers = async () => { export type UserWithPosts = Prisma.UserGetPayload<typeof userWithPosts>
const users = await prisma.user.findMany({
export const getAllUsers = async (
options?: Prisma.UserFindManyArgs
): Promise<User[] | UserWithPosts[]> => {
const users = (await prisma.user.findMany({
select: { select: {
id: true, id: true,
email: true, email: true,
@ -242,8 +232,9 @@ export const getAllUsers = async () => {
displayName: true, displayName: true,
posts: true, posts: true,
createdAt: true createdAt: true
} },
}) ...options
})) as User[] | UserWithPosts[]
return users return users
} }
@ -265,7 +256,7 @@ export const searchPosts = async (
OR: [ OR: [
{ {
title: { title: {
search: query, search: query
}, },
authorId: userId, authorId: userId,
visibility: publicOnly ? "public" : undefined visibility: publicOnly ? "public" : undefined
@ -275,7 +266,7 @@ export const searchPosts = async (
some: { some: {
content: { content: {
in: [Buffer.from(query)] in: [Buffer.from(query)]
}, }
} }
}, },
visibility: publicOnly ? "public" : undefined visibility: publicOnly ? "public" : undefined

View file

@ -6,10 +6,10 @@ const nextConfig = {
experimental: { experimental: {
// esmExternals: true, // esmExternals: true,
appDir: true, appDir: true,
serverComponentsExternalPackages: ['prisma'], serverComponentsExternalPackages: ["prisma", "@prisma/client"],
}, },
output: "standalone", output: "standalone",
async rewrites() { rewrites() {
return [ return [
{ {
source: "/file/raw/:id", source: "/file/raw/:id",
@ -21,7 +21,6 @@ const nextConfig = {
} }
] ]
} }
} }
export default bundleAnalyzer({ enabled: process.env.ANALYZE === "true" })( export default bundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(

View file

@ -28,7 +28,7 @@
"jest": "^29.3.1", "jest": "^29.3.1",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"next": "13.0.8-canary.0", "next": "13.0.8-canary.0",
"next-auth": "^4.18.4", "next-auth": "^4.18.6",
"prisma": "^4.7.1", "prisma": "^4.7.1",
"react": "18.2.0", "react": "18.2.0",
"react-datepicker": "4.8.0", "react-datepicker": "4.8.0",

View file

@ -3,6 +3,7 @@ import { parseQueryParam } from "@lib/server/parse-query-param"
import { NextApiRequest, NextApiResponse } from "next" import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from "lib/server/prisma" import { prisma } from "lib/server/prisma"
import { getSession } from "next-auth/react" import { getSession } from "next-auth/react"
import { deleteUser } from "../user/[id]"
const actions = [ const actions = [
"user", "user",
@ -94,11 +95,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(400).json({ error: "Invalid request" }) return res.status(400).json({ error: "Invalid request" })
} }
const user = await prisma.user.delete({ await deleteUser(userId)
where: { id: userId }
})
return res.status(200).json(user) return res.status(200).send("User deleted")
case "delete-post": case "delete-post":
const { postId } = req.body const { postId } = req.body
if (!postId) { if (!postId) {

View file

@ -42,9 +42,42 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}) })
case "GET": case "GET":
return res.json(currUser) return res.json(currUser)
case "DELETE":
if (currUser?.role !== "admin" && currUser?.id !== id) {
return res.status(403).json({ message: "Unauthorized" })
}
await deleteUser(id)
default: default:
return res.status(405).json({ message: "Method not allowed" }) return res.status(405).json({ message: "Method not allowed" })
} }
} }
export default withMethods(["GET", "PUT"], handler) export default withMethods(["GET", "PUT", "DELETE"], handler)
// valid jsdoc
/**
* @description Deletes a user and all of their posts, files, and accounts
* @warning This function does not perform any authorization checks
*/
export async function deleteUser(id: string | undefined) {
// first delete all of the user's posts
await prisma.post.deleteMany({
where: {
authorId: id
}
})
await prisma.user.delete({
where: {
id
},
include: {
posts: true,
accounts: true,
sessions: true
}
})
}

View file

@ -28,7 +28,7 @@ specifiers:
jest: ^29.3.1 jest: ^29.3.1
lodash.debounce: ^4.0.8 lodash.debounce: ^4.0.8
next: 13.0.8-canary.0 next: 13.0.8-canary.0
next-auth: ^4.18.4 next-auth: ^4.18.6
next-unused: 0.0.6 next-unused: 0.0.6
prettier: 2.6.2 prettier: 2.6.2
prisma: ^4.7.1 prisma: ^4.7.1
@ -46,7 +46,7 @@ specifiers:
typescript-plugin-css-modules: 3.4.0 typescript-plugin-css-modules: 3.4.0
dependencies: dependencies:
'@next-auth/prisma-adapter': 1.0.5_4eojhct6t46nl4awizrjr4dkya '@next-auth/prisma-adapter': 1.0.5_64qbzg5ec56bux2misz3l4u6g4
'@next/eslint-plugin-next': 13.0.7-canary.4 '@next/eslint-plugin-next': 13.0.7-canary.4
'@prisma/client': 4.7.1_prisma@4.7.1 '@prisma/client': 4.7.1_prisma@4.7.1
'@radix-ui/react-dialog': 1.0.2_jbvntnid6ohjelon6ccj5dhg2u '@radix-ui/react-dialog': 1.0.2_jbvntnid6ohjelon6ccj5dhg2u
@ -61,7 +61,7 @@ dependencies:
jest: 29.3.1_@types+node@17.0.23 jest: 29.3.1_@types+node@17.0.23
lodash.debounce: 4.0.8 lodash.debounce: 4.0.8
next: 13.0.8-canary.0_biqbaboplfbrettd7655fr4n2y next: 13.0.8-canary.0_biqbaboplfbrettd7655fr4n2y
next-auth: 4.18.4_rhfownvlqkszea7w3lnpwl7bzy next-auth: 4.18.6_rhfownvlqkszea7w3lnpwl7bzy
prisma: 4.7.1 prisma: 4.7.1
react: 18.2.0 react: 18.2.0
react-datepicker: 4.8.0_biqbaboplfbrettd7655fr4n2y react-datepicker: 4.8.0_biqbaboplfbrettd7655fr4n2y
@ -773,14 +773,14 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.14 '@jridgewell/sourcemap-codec': 1.4.14
dev: false dev: false
/@next-auth/prisma-adapter/1.0.5_4eojhct6t46nl4awizrjr4dkya: /@next-auth/prisma-adapter/1.0.5_64qbzg5ec56bux2misz3l4u6g4:
resolution: {integrity: sha512-VqMS11IxPXrPGXw6Oul6jcyS/n8GLOWzRMrPr3EMdtD6eOalM6zz05j08PcNiis8QzkfuYnCv49OvufTuaEwYQ==} resolution: {integrity: sha512-VqMS11IxPXrPGXw6Oul6jcyS/n8GLOWzRMrPr3EMdtD6eOalM6zz05j08PcNiis8QzkfuYnCv49OvufTuaEwYQ==}
peerDependencies: peerDependencies:
'@prisma/client': '>=2.26.0 || >=3' '@prisma/client': '>=2.26.0 || >=3'
next-auth: ^4 next-auth: ^4
dependencies: dependencies:
'@prisma/client': 4.7.1_prisma@4.7.1 '@prisma/client': 4.7.1_prisma@4.7.1
next-auth: 4.18.4_rhfownvlqkszea7w3lnpwl7bzy next-auth: 4.18.6_rhfownvlqkszea7w3lnpwl7bzy
dev: false dev: false
/@next/bundle-analyzer/13.0.7-canary.4: /@next/bundle-analyzer/13.0.7-canary.4:
@ -5105,8 +5105,8 @@ packages:
dev: true dev: true
optional: true optional: true
/next-auth/4.18.4_rhfownvlqkszea7w3lnpwl7bzy: /next-auth/4.18.6_rhfownvlqkszea7w3lnpwl7bzy:
resolution: {integrity: sha512-tvXOabxv5U/y6ib56XPkOnc/48tYc+xT6GNOLREIme8WVGYHDTc3CGEfe2+0bVCWAm0ax/GYXH0By5NFoaJDww==} resolution: {integrity: sha512-0TQwbq5X9Jyd1wUVYUoyvHJh4JWXeW9UOcMEl245Er/Y5vsSbyGJHt8M7xjRMzk9mORVMYehoMdERgyiq/jCgA==}
engines: {node: ^12.19.0 || ^14.15.0 || ^16.13.0 || ^18.12.0} engines: {node: ^12.19.0 || ^14.15.0 || ^16.13.0 || ^18.12.0}
peerDependencies: peerDependencies:
next: ^12.2.5 || ^13 next: ^12.2.5 || ^13