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"
import { useState } from "react"
import { startTransition, useEffect, useRef, useState } from "react"
import styles from "./auth.module.css"
import Link from "../../components/link"
import { signIn } from "next-auth/react"
import { getSession, signIn } from "next-auth/react"
import Input from "@components/input"
import Button from "@components/button"
import Note from "@components/note"
import { GitHub } from "react-feather"
import { useToasts } from "@components/toasts"
import { useRouter, useSearchParams } from "next/navigation"
import Note from "@components/note"
const Auth = ({
page,
requiresServerPassword
requiresServerPassword,
isGithubEnabled
}: {
page: "signup" | "signin"
requiresServerPassword?: boolean
isGithubEnabled?: boolean
}) => {
const [serverPassword, setServerPassword] = useState("")
const [errorMsg, setErrorMsg] = useState("")
const { setToast } = useToasts()
const signingIn = page === "signin"
const router = useRouter()
const signText = signingIn ? "In" : "Up"
const [username, setUsername] = 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 (
<div className={styles.container}>
@ -28,32 +82,14 @@ const Auth = ({
<div className={styles.formContentSpace}>
<h1>Sign {signText}</h1>
</div>
{/* <form onSubmit={handleSubmit}> */}
<form>
<form onSubmit={handleSubmit}>
<div className={styles.formGroup}>
<Input
type="text"
id="username"
value={username}
onChange={(event) => setUsername(event.currentTarget.value)}
placeholder="Username"
required
minLength={3}
width="100%"
aria-label="Username"
/>
<Input
type="password"
id="password"
value={password}
onChange={(event) => setPassword(event.currentTarget.value)}
placeholder="Password"
required
minLength={6}
width="100%"
aria-label="Password"
/>
{requiresServerPassword && (
{requiresServerPassword ? (
<>
{" "}
<Note type="info">
The server administrator has set a password for this server.
</Note>
<Input
type="password"
id="server-password"
@ -62,26 +98,41 @@ const Auth = ({
setServerPassword(event.currentTarget.value)
}
placeholder="Server Password"
required
required={true}
width="100%"
aria-label="Server Password"
/>
)}
<Button
width={"100%"}
type="submit"
onClick={(e) => {
e.preventDefault()
signIn("credentials", {
username,
password,
callbackUrl: "/"
})
}}
>
<hr style={{ width: "100%" }} />
</>
) : null}
<Input
type="text"
id="username"
value={username}
onChange={handleChangeUsername}
placeholder="Username"
required={true}
minLength={3}
width="100%"
aria-label="Username"
/>
<Input
type="password"
id="password"
value={password}
onChange={handleChangePassword}
placeholder="Password"
required={true}
minLength={6}
width="100%"
aria-label="Password"
/>
<Button width={"100%"} type="submit">
Sign {signText}
</Button>
<hr style={{ width: "100%" }} />
{isGithubEnabled ? <hr style={{ width: "100%" }} /> : null}
{isGithubEnabled ? (
<Button
type="submit"
buttonType="primary"
@ -93,12 +144,14 @@ const Auth = ({
onClick={(e) => {
e.preventDefault()
signIn("github", {
callbackUrl: "/"
callbackUrl: "/",
registration_password: serverPassword
})
}}
>
Sign {signText.toLowerCase()} with GitHub
</Button>
) : null}
</div>
<div className={styles.formContentSpace}>
{signingIn ? (
@ -117,7 +170,6 @@ const Auth = ({
</p>
)}
</div>
{errorMsg && <Note type="error">{errorMsg}</Note>}
</form>
</div>
</div>

View file

@ -1,5 +1,10 @@
import config from "@lib/config"
import Auth from "../components"
export default function SignInPage() {
return <Auth page="signin" />
export function isGithubEnabled() {
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 { getRequiresPasscode } from "pages/api/auth/requires-passcode"
import { isGithubEnabled } from "../signin/page"
const getPasscode = async () => {
return await getRequiresPasscode()
@ -7,5 +8,5 @@ const getPasscode = async () => {
export default async function SignUpPage() {
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") {
router.push("/login")
router.push("/signin")
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() {
return (

View file

@ -1,100 +1,55 @@
import { getAllPosts, getAllUsers } from "@lib/server/prisma"
import { Spinner } from "@components/spinner"
import styles from "./admin.module.css"
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>
)
}
import { getAllPosts, getAllUsers } from "@lib/server/prisma"
import { PostTable, UserTable } from "./components/tables"
export default async function AdminPage() {
const usersPromise = getAllUsers()
const usersPromise = getAllUsers({
select: {
id: true,
name: true,
createdAt: true
}
})
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 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 (
<div>
<h1>Admin</h1>
<h2>Users</h2>
<UserTable users={users} />
<UserTable users={serializedUsers} />
<h2>Posts</h2>
<PostTable posts={posts} />
<PostTable posts={serializedPosts} />
</div>
)
}

View file

@ -34,6 +34,7 @@ type Tab = {
const Header = () => {
const session = useSession()
console.log("session", session)
const isSignedIn = session?.status === "authenticated"
const isAdmin = session?.data?.user?.role === "admin"
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
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, className, width, height, labelClassName, ...props }, ref) => {
({ label, className, required, width, height, labelClassName, ...props }, ref) => {
return (
<div
className={styles.wrapper}

View file

@ -13,9 +13,24 @@ const getWelcomeData = async () => {
export default async function Page() {
const { content, rendered, title } = await getWelcomeData()
const getPostsPromise = getAllPosts({
where: { visibility: "public" },
include: {
files: true
select: {
id: 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 = {
is_production: boolean
enable_admin: boolean
@ -9,6 +8,7 @@ type Config = {
github_client_id: string
github_client_secret: string
nextauth_secret: string
credential_auth: boolean
}
type EnvironmentValue = string | undefined
@ -77,6 +77,9 @@ export const config = (env: Environment): Config => {
github_client_id: env.GITHUB_CLIENT_ID ?? "",
github_client_secret: env.GITHUB_CLIENT_SECRET ?? "",
nextauth_secret: throwIfUndefined("NEXTAUTH_SECRET"),
credential_auth: stringToBoolean(
developmentDefault("CREDENTIAL_AUTH", "true")
)
}
return config
}

View file

@ -5,21 +5,60 @@ import CredentialsProvider from "next-auth/providers/credentials"
import { prisma } from "@lib/server/prisma"
import config from "@lib/config"
import * as crypto from "crypto"
import { Provider } from "next-auth/providers"
const providers: NextAuthOptions["providers"] = [
const credentialsOptions = () => {
const options: Record<string, any> = {
username: {
label: "Username",
required: true,
type: "text"
},
password: {
label: "Password",
required: true,
type: "password"
}
}
if (config.registration_password) {
options["registration_password"] = {
label: "Server Password",
type: "password",
optional: true
}
}
return options
}
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: "Credentials",
credentials: {
username: { label: "Username", type: "text", placeholder: "jsmith" },
password: { label: "Password", type: "password" }
},
name: "Drift",
credentials: credentialsOptions(),
async authorize(credentials) {
if (!credentials) {
return null
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({
@ -37,51 +76,69 @@ const providers: NextAuthOptions["providers"] = [
const hashedPassword = crypto
.createHash("sha256")
.update
(credentials
.password
+ config.nextauth_secret)
.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 &&
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")
}
return null
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
}
}
})
)
}
return providers
}
export const authOptions: NextAuthOptions = {
// see https://github.com/prisma/prisma/issues/16117 / https://github.com/shadcn/taxonomy
adapter: PrismaAdapter(prisma as any),
adapter: PrismaAdapter(prisma),
session: {
strategy: "jwt"
},
pages: {
signIn: "/signin"
// TODO
// error: "/auth/error",
signIn: "/signin",
error: "/signin"
},
providers,
providers: providers(),
callbacks: {
async session({ token, session }) {
if (token) {
@ -94,6 +151,7 @@ export const authOptions: NextAuthOptions = {
return session
},
async jwt({ token, user }) {
const dbUser = await prisma.user.findFirst({
where: {

View file

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

View file

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

View file

@ -28,7 +28,7 @@
"jest": "^29.3.1",
"lodash.debounce": "^4.0.8",
"next": "13.0.8-canary.0",
"next-auth": "^4.18.4",
"next-auth": "^4.18.6",
"prisma": "^4.7.1",
"react": "18.2.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 { prisma } from "lib/server/prisma"
import { getSession } from "next-auth/react"
import { deleteUser } from "../user/[id]"
const actions = [
"user",
@ -94,11 +95,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(400).json({ error: "Invalid request" })
}
const user = await prisma.user.delete({
where: { id: userId }
})
await deleteUser(userId)
return res.status(200).json(user)
return res.status(200).send("User deleted")
case "delete-post":
const { postId } = req.body
if (!postId) {

View file

@ -42,9 +42,42 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
})
case "GET":
return res.json(currUser)
case "DELETE":
if (currUser?.role !== "admin" && currUser?.id !== id) {
return res.status(403).json({ message: "Unauthorized" })
}
await deleteUser(id)
default:
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
lodash.debounce: ^4.0.8
next: 13.0.8-canary.0
next-auth: ^4.18.4
next-auth: ^4.18.6
next-unused: 0.0.6
prettier: 2.6.2
prisma: ^4.7.1
@ -46,7 +46,7 @@ specifiers:
typescript-plugin-css-modules: 3.4.0
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
'@prisma/client': 4.7.1_prisma@4.7.1
'@radix-ui/react-dialog': 1.0.2_jbvntnid6ohjelon6ccj5dhg2u
@ -61,7 +61,7 @@ dependencies:
jest: 29.3.1_@types+node@17.0.23
lodash.debounce: 4.0.8
next: 13.0.8-canary.0_biqbaboplfbrettd7655fr4n2y
next-auth: 4.18.4_rhfownvlqkszea7w3lnpwl7bzy
next-auth: 4.18.6_rhfownvlqkszea7w3lnpwl7bzy
prisma: 4.7.1
react: 18.2.0
react-datepicker: 4.8.0_biqbaboplfbrettd7655fr4n2y
@ -773,14 +773,14 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.14
dev: false
/@next-auth/prisma-adapter/1.0.5_4eojhct6t46nl4awizrjr4dkya:
/@next-auth/prisma-adapter/1.0.5_64qbzg5ec56bux2misz3l4u6g4:
resolution: {integrity: sha512-VqMS11IxPXrPGXw6Oul6jcyS/n8GLOWzRMrPr3EMdtD6eOalM6zz05j08PcNiis8QzkfuYnCv49OvufTuaEwYQ==}
peerDependencies:
'@prisma/client': '>=2.26.0 || >=3'
next-auth: ^4
dependencies:
'@prisma/client': 4.7.1_prisma@4.7.1
next-auth: 4.18.4_rhfownvlqkszea7w3lnpwl7bzy
next-auth: 4.18.6_rhfownvlqkszea7w3lnpwl7bzy
dev: false
/@next/bundle-analyzer/13.0.7-canary.4:
@ -5105,8 +5105,8 @@ packages:
dev: true
optional: true
/next-auth/4.18.4_rhfownvlqkszea7w3lnpwl7bzy:
resolution: {integrity: sha512-tvXOabxv5U/y6ib56XPkOnc/48tYc+xT6GNOLREIme8WVGYHDTc3CGEfe2+0bVCWAm0ax/GYXH0By5NFoaJDww==}
/next-auth/4.18.6_rhfownvlqkszea7w3lnpwl7bzy:
resolution: {integrity: sha512-0TQwbq5X9Jyd1wUVYUoyvHJh4JWXeW9UOcMEl245Er/Y5vsSbyGJHt8M7xjRMzk9mORVMYehoMdERgyiq/jCgA==}
engines: {node: ^12.19.0 || ^14.15.0 || ^16.13.0 || ^18.12.0}
peerDependencies:
next: ^12.2.5 || ^13