fix admin page, expiring view, displayName setting/field

This commit is contained in:
Max Leiter 2022-11-14 01:28:40 -08:00
parent 0627ab7396
commit 2b783145d4
25 changed files with 2079 additions and 442 deletions

View file

@ -15,7 +15,9 @@ const NewFromExisting = async ({
return notFound() return notFound()
} }
const post = await getPostById(id, true) const post = await getPostById(id, {
withFiles: true,
})
return <NewPost initialPost={post} newPostParent={id} /> return <NewPost initialPost={post} newPostParent={id} />
} }

View file

@ -4,7 +4,7 @@ import VisibilityBadge from "@components/badges/visibility-badge"
import DocumentComponent from "./view-document" import DocumentComponent from "./view-document"
import styles from "./post-page.module.css" import styles from "./post-page.module.css"
import { Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core/dist" import { Button, ButtonGroup, useMediaQuery } from "@geist-ui/core/dist"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Archive from "@geist-ui/icons/archive" import Archive from "@geist-ui/icons/archive"
import Edit from "@geist-ui/icons/edit" import Edit from "@geist-ui/icons/edit"
@ -16,47 +16,54 @@ import ExpirationBadge from "@components/badges/expiration-badge"
import CreatedAgoBadge from "@components/badges/created-ago-badge" import CreatedAgoBadge from "@components/badges/created-ago-badge"
import PasswordModalPage from "./password-modal-wrapper" import PasswordModalPage from "./password-modal-wrapper"
import VisibilityControl from "@components/badges/visibility-control" import VisibilityControl from "@components/badges/visibility-control"
import { File, PostWithFiles } from "@lib/server/prisma" import { File, PostWithFilesAndAuthor } from "@lib/server/prisma"
type Props = { type Props = {
post: string | PostWithFiles post: string | PostWithFilesAndAuthor
isProtected?: boolean isProtected?: boolean
isAuthor?: boolean isAuthor?: boolean
} }
const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => { const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
const [post, setPost] = useState<PostWithFiles>(typeof initialPost === "string" ? JSON.parse(initialPost) : initialPost) const [post, setPost] = useState<PostWithFilesAndAuthor>(
const [visibility, setVisibility] = useState<string>(post.visibility) typeof initialPost === "string" ? JSON.parse(initialPost) : initialPost
const [isExpired, setIsExpired] = useState(
post.expiresAt ? new Date(post.expiresAt) < new Date() : null
) )
const [isLoading, setIsLoading] = useState(true) const [visibility, setVisibility] = useState<string>(post.visibility)
const router = useRouter() const router = useRouter()
const isMobile = useMediaQuery("mobile") const isMobile = useMediaQuery("mobile")
useEffect(() => { useEffect(() => {
if (!isAuthor && isExpired) {
router.push("/expired")
}
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
if (!isAuthor && expirationDate < new Date()) {
router.push("/expired")
} else {
setIsLoading(false)
}
let interval: NodeJS.Timer | null = null
if (post.expiresAt) { if (post.expiresAt) {
interval = setInterval(() => { if (new Date(post.expiresAt) < new Date()) {
if (!isAuthor) {
router.push("/expired")
}
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "") const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
if (expirationDate < new Date()) setIsExpired(true) if (!isAuthor && expirationDate < new Date()) {
}, 4000) router.push("/expired")
}
let interval: NodeJS.Timer | null = null
if (post.expiresAt) {
interval = setInterval(() => {
const expirationDate = new Date(
post.expiresAt ? post.expiresAt : ""
)
if (expirationDate < new Date()) {
if (!isAuthor) {
router.push("/expired")
}
clearInterval(interval!)
}
}, 4000)
}
return () => {
if (interval) clearInterval(interval)
}
}
} }
return () => { }, [isAuthor, post.expiresAt, router])
if (interval) clearInterval(interval)
}
}, [isExpired, isAuthor, post.expiresAt, router])
const download = async () => { const download = async () => {
if (!post.files) return if (!post.files) return
@ -85,11 +92,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
router.push(`/post/${post.parentId}`) router.push(`/post/${post.parentId}`)
} }
if (isLoading) { const isAvailable = !isProtected && post.title
return <></>
}
const isAvailable = !isExpired && !isProtected && post.title
return ( return (
<> <>
@ -128,7 +131,7 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
</ButtonGroup> </ButtonGroup>
</span> </span>
<span className={styles.title}> <span className={styles.title}>
<h3>{post.title}</h3> <h3>{post.title} <span style={{color: 'var(--gray)'}}>by {post.author?.displayName}</span></h3>
<span className={styles.badges}> <span className={styles.badges}>
<VisibilityBadge visibility={visibility} /> <VisibilityBadge visibility={visibility} />
<CreatedAgoBadge createdAt={post.createdAt} /> <CreatedAgoBadge createdAt={post.createdAt} />

View file

@ -22,7 +22,10 @@ export type PostProps = {
// } // }
const getPost = async (id: string) => { const getPost = async (id: string) => {
const post = await getPostById(id, true) const post = await getPostById(id, {
withFiles: true,
withAuthor: true,
})
const user = await getCurrentUser() const user = await getCurrentUser()
if (!post) { if (!post) {
@ -49,12 +52,21 @@ const getPost = async (id: string) => {
if (post.visibility === "protected" && !isAuthorOrAdmin) { if (post.visibility === "protected" && !isAuthorOrAdmin) {
return { return {
post, // post,
isProtected: true, isProtected: true,
isAuthor: isAuthorOrAdmin isAuthor: isAuthorOrAdmin
} }
} }
// if expired
if (post.expiresAt && !isAuthorOrAdmin) {
const expirationDate = new Date(post.expiresAt)
if (expirationDate < new Date()) {
return notFound()
}
}
return { post, isAuthor: isAuthorOrAdmin } return { post, isAuthor: isAuthorOrAdmin }
} }

View file

@ -1,25 +1,23 @@
"use client" import { getAllPosts, getAllUsers } from "@lib/server/prisma"
import { getCurrentUser } from "@lib/server/session"
import { notFound } from "next/navigation"
import styles from "./admin.module.css" import styles from "./admin.module.css"
import PostTable from "./post-table" import PostTable from "./post-table"
import UserTable from "./user-table" import UserTable from "./user-table"
export const adminFetcher = async ( const Admin = async () => {
url: string, const user = await getCurrentUser()
options?: { if (!user) {
method?: string return notFound()
body?: any
} }
) =>
fetch("/api/admin" + url, {
method: options?.method || "GET",
headers: {
"Content-Type": "application/json",
},
body: options?.body && JSON.stringify(options.body)
})
const Admin = () => { if (user.role !== "admin") {
return notFound()
}
const posts = await getAllPosts()
const users = await getAllUsers()
return ( return (
<div className={styles.adminWrapper}> <div className={styles.adminWrapper}>
<h2>Administration</h2> <h2>Administration</h2>
@ -31,8 +29,8 @@ const Admin = () => {
gap: 4 gap: 4
}} }}
> >
<UserTable /> <UserTable users={users} />
<PostTable /> <PostTable posts={posts} />
</div> </div>
</div> </div>
) )

View file

@ -1,25 +1,17 @@
'use client';
import SettingsGroup from "@components/settings-group" import SettingsGroup from "@components/settings-group"
import { Fieldset, useToasts } from "@geist-ui/core/dist" import { Fieldset, useToasts } from "@geist-ui/core/dist"
import byteToMB from "@lib/byte-to-mb" import byteToMB from "@lib/byte-to-mb"
import { Post } from "@lib/types" import { Post } from "@lib/server/prisma";
import Table from "rc-table" import Table from "rc-table"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { adminFetcher } from "./admin"
import ActionDropdown from "./action-dropdown" import ActionDropdown from "./action-dropdown"
const PostTable = () => { const PostTable = ({
const [posts, setPosts] = useState<Post[]>() posts,
const { setToast } = useToasts() }: {
posts: Post[]
useEffect(() => { }) => {
const fetchPosts = async () => {
const res = await adminFetcher("/posts")
const data = await res.json()
setPosts(data)
}
fetchPosts()
}, [])
const tablePosts = useMemo( const tablePosts = useMemo(
() => () =>
posts?.map((post) => { posts?.map((post) => {

View file

@ -1,28 +1,23 @@
"use client"
import { Fieldset, useToasts } from "@geist-ui/core/dist" import { Fieldset, useToasts } from "@geist-ui/core/dist"
import { User } from "@lib/types"
import { useEffect, useMemo, useState } from "react"
import { adminFetcher } from "./admin"
import Table from "rc-table" import Table from "rc-table"
import ActionDropdown from "./action-dropdown" import ActionDropdown from "./action-dropdown"
import SettingsGroup from "@components/settings-group" import SettingsGroup from "@components/settings-group"
import type { User, UserWithPosts } from "@lib/server/prisma"
import { useState } from "react"
const UserTable = () => { const UserTable = ({ users: initial }: { users: UserWithPosts[] }) => {
const [users, setUsers] = useState<User[]>() const [users, setUsers] = useState(initial)
const { setToast } = useToasts() const { setToast } = useToasts()
useEffect(() => { console.log(initial)
const fetchUsers = async () => {
const res = await adminFetcher("/users")
const data = await res.json()
setUsers(data)
}
fetchUsers()
}, [])
const toggleRole = async (id: string, role: "admin" | "user") => { const toggleRole = async (id: string, role: "admin" | "user") => {
const res = await adminFetcher("/users/toggle-role", { const res = await fetch("/api/admin?action=toggle-role", {
method: "POST", method: "POST",
body: { id, role } body: JSON.stringify({
userId: id,
role
})
}) })
const json = await res.json() const json = await res.json()
@ -47,7 +42,7 @@ const UserTable = () => {
}) })
} else { } else {
setToast({ setToast({
text: "Something went wrong", text: "Something went wrong",
type: "error" type: "error"
}) })
} }
@ -56,11 +51,20 @@ const UserTable = () => {
const deleteUser = async (id: string) => { const deleteUser = async (id: string) => {
const confirm = window.confirm("Are you sure you want to delete this user?") const confirm = window.confirm("Are you sure you want to delete this user?")
if (!confirm) return if (!confirm) return
const res = await adminFetcher(`/users/${id}`, { // const res = await adminFetcher(`/users/${id}`, {
method: "DELETE" // method: "DELETE"
// })
const res = await fetch("/api/admin?action=delete-user", {
method: "POST",
body: JSON.stringify({
userId: id
})
}) })
const json = await res.json() setUsers((users) => {
const newUsers = users?.filter((user) => user.id !== id)
return newUsers
})
if (res.status === 200) { if (res.status === 200) {
setToast({ setToast({
@ -75,30 +79,24 @@ const UserTable = () => {
} }
} }
const tableUsers = useMemo( const tableUsers = users?.map((user) => {
() => return {
users?.map((user) => { id: user.id,
return { displayName: user.displayName,
id: user.id, posts: user.posts?.length || 0,
username: user.username, createdAt: `${new Date(user.createdAt)} ${new Date(
posts: user.posts?.length || 0, user.createdAt
createdAt: `${new Date( ).toLocaleTimeString()}`,
user.createdAt role: user.role,
).toLocaleDateString()} ${new Date( actions: ""
user.createdAt }
).toLocaleTimeString()}`, })
role: user.role,
actions: ""
}
}),
[users]
)
const usernameColumns = [ const usernameColumns = [
{ {
title: "Username", title: "Name",
dataIndex: "username", dataIndex: "displayName",
key: "username", key: "displayName",
width: 50 width: 50
}, },
{ {

View file

@ -1,18 +0,0 @@
import { getCurrentUser } from "@lib/server/session"
import Admin from "./components/admin"
import { notFound } from "next/navigation"
const AdminPage = async () => {
const user = await getCurrentUser();
if (!user) {
return notFound()
}
if (user.role !== "admin") {
return notFound()
}
return <Admin />
}
export default AdminPage

View file

@ -37,14 +37,13 @@ type Tab = {
href?: string href?: string
} }
const Header = ({ signedIn = false }) => { const Header = ({ signedIn = false, isAdmin = false }) => {
const pathname = usePathname() const pathname = usePathname()
const [expanded, setExpanded] = useState<boolean>(false) const [expanded, setExpanded] = useState<boolean>(false)
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true }) const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
const isMobile = useMediaQuery("xs", { match: "down" }) const isMobile = useMediaQuery("xs", { match: "down" })
// const { status } = useSession() // const { status } = useSession()
// const signedIn = status === "authenticated" // const signedIn = status === "authenticated"
const [pages, setPages] = useState<Tab[]>([])
const { setTheme, resolvedTheme } = useTheme() const { setTheme, resolvedTheme } = useTheme()
useEffect(() => { useEffect(() => {
@ -57,7 +56,7 @@ const Header = ({ signedIn = false }) => {
} }
}, [isMobile]) }, [isMobile])
useEffect(() => { const getPages = () => {
const defaultPages: Tab[] = [ const defaultPages: Tab[] = [
{ {
name: isMobile ? "GitHub" : "", name: isMobile ? "GitHub" : "",
@ -76,8 +75,17 @@ const Header = ({ signedIn = false }) => {
} }
] ]
if (isAdmin) {
defaultPages.push({
name: "admin",
icon: <SettingsIcon />,
value: "admin",
href: "/admin"
})
}
if (signedIn) if (signedIn)
setPages([ return [
{ {
name: "new", name: "new",
icon: <NewIcon />, icon: <NewIcon />,
@ -103,9 +111,9 @@ const Header = ({ signedIn = false }) => {
onClick: () => signOut() onClick: () => signOut()
}, },
...defaultPages ...defaultPages
]) ]
else else
setPages([ return [
{ {
name: "home", name: "home",
icon: <HomeIcon />, icon: <HomeIcon />,
@ -125,67 +133,46 @@ const Header = ({ signedIn = false }) => {
href: "/signup" href: "/signup"
}, },
...defaultPages ...defaultPages
]) ]
// if (userData?.role === "admin") { }
// setPages((pages) => [
// ...pages,
// {
// name: "admin",
// icon: <SettingsIcon />,
// value: "admin",
// href: "/admin"
// }
// ])
// }
// TODO: investigate deps causing infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile, resolvedTheme])
const onTabChange = useCallback( const pages = getPages()
(tab: string) => {
if (typeof window === "undefined") return
const match = pages.find((page) => page.value === tab)
if (match?.onClick) {
match.onClick()
}
},
[pages]
)
const getButton = useCallback( const onTabChange = (tab: string) => {
(tab: Tab) => { if (typeof window === "undefined") return
const activeStyle = pathname === tab.href ? styles.active : "" const match = pages.find((page) => page.value === tab)
if (tab.onClick) { if (match?.onClick) {
return ( match.onClick()
<Button }
auto={isMobile ? false : true} }
key={tab.value}
icon={tab.icon} const getButton = (tab: Tab) => {
onClick={() => onTabChange(tab.value)} const activeStyle = pathname === tab.href ? styles.active : ""
className={`${styles.tab} ${activeStyle}`} if (tab.onClick) {
shadow={false} return (
> <Button
auto={isMobile ? false : true}
key={tab.value}
icon={tab.icon}
onClick={() => onTabChange(tab.value)}
className={`${styles.tab} ${activeStyle}`}
shadow={false}
>
{tab.name ? tab.name : undefined}
</Button>
)
} else if (tab.href) {
return (
<Link key={tab.value} href={tab.href} className={styles.tab}>
<Button auto={isMobile ? false : true} icon={tab.icon} shadow={false}>
{tab.name ? tab.name : undefined} {tab.name ? tab.name : undefined}
</Button> </Button>
) </Link>
} else if (tab.href) { )
return ( }
<Link key={tab.value} href={tab.href} className={styles.tab}> }
<Button
auto={isMobile ? false : true}
icon={tab.icon}
shadow={false}
>
{tab.name ? tab.name : undefined}
</Button>
</Link>
)
}
},
[isMobile, onTabChange, pathname]
)
const buttons = useMemo(() => pages.map(getButton), [pages, getButton]) const buttons = pages.map(getButton)
return ( return (
<Page.Header> <Page.Header>

View file

@ -3,6 +3,7 @@ import { ServerThemeProvider } from "next-themes"
import { LayoutWrapper } from "./root-layout-wrapper" import { LayoutWrapper } from "./root-layout-wrapper"
import styles from '@styles/Home.module.css'; import styles from '@styles/Home.module.css';
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getSession } from "@lib/server/session";
interface RootLayoutProps { interface RootLayoutProps {
children: React.ReactNode children: React.ReactNode
@ -10,8 +11,7 @@ interface RootLayoutProps {
export default async function RootLayout({ children }: RootLayoutProps) { export default async function RootLayout({ children }: RootLayoutProps) {
// TODO: this opts out of SSG // TODO: this opts out of SSG
const cookiesList = cookies(); const session = await getSession()
const hasNextAuth = cookiesList.get("next-auth.session-token") !== undefined;
return ( return (
<ServerThemeProvider <ServerThemeProvider
disableTransitionOnChange disableTransitionOnChange
@ -23,7 +23,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
</head> </head>
<body className={styles.main}> <body className={styles.main}>
<LayoutWrapper signedIn={hasNextAuth}>{children}</LayoutWrapper> <LayoutWrapper signedIn={Boolean(session?.user)} isAdmin={session?.user.role === "admin"}>{children}</LayoutWrapper>
</body> </body>
</html> </html>
</ServerThemeProvider> </ServerThemeProvider>

View file

@ -7,14 +7,13 @@ import * as RadixTooltip from "@radix-ui/react-tooltip"
export function LayoutWrapper({ export function LayoutWrapper({
children, children,
signedIn signedIn,
isAdmin,
}: { }: {
children: React.ReactNode children: React.ReactNode
signedIn?: boolean signedIn?: boolean
isAdmin?: boolean
}) { }) {
const skeletonBaseColor = "var(--light-gray)"
const skeletonHighlightColor = "var(--lighter-gray)"
const customTheme = Themes.createFromLight({ const customTheme = Themes.createFromLight({
type: "custom", type: "custom",
palette: { palette: {
@ -63,7 +62,7 @@ export function LayoutWrapper({
<CssBaseline /> <CssBaseline />
<Page width={"100%"}> <Page width={"100%"}>
<Page.Header> <Page.Header>
<Header signedIn={signedIn} /> <Header isAdmin={isAdmin} signedIn={signedIn} />
</Page.Header> </Page.Header>
{children} {children}
</Page> </Page>

View file

@ -34,7 +34,7 @@ const Profile = ({ user }: { user: User }) => {
bio bio
} }
const res = await fetch("/server-api/user/profile", { const res = await fetch(`/api/user/${user.id}`, {
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View file

@ -1,6 +1,4 @@
import Header from "@components/header"
import SettingsGroup from "../components/settings-group" import SettingsGroup from "../components/settings-group"
import Password from "app/settings/components/sections/password"
import Profile from "app/settings/components/sections/profile" import Profile from "app/settings/components/sections/profile"
import { authOptions } from "@lib/server/auth" import { authOptions } from "@lib/server/auth"
import { getCurrentUser } from "@lib/server/session" import { getCurrentUser } from "@lib/server/session"

View file

@ -1,5 +1,3 @@
@import "./inter.css";
:root { :root {
/* Spacing */ /* Spacing */
--gap-quarter: 0.25rem; --gap-quarter: 0.25rem;
@ -163,7 +161,7 @@ code {
isolation: isolate; isolation: isolate;
} }
/* TODO: this should not be necessary. */ /* TODO: these should not be necessary. */
main { main {
margin-top: 0 !important; margin-top: 0 !important;
padding-top: 0 !important; padding-top: 0 !important;

View file

@ -1,100 +0,0 @@
/* latin */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 200;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 300;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 700;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 800;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 900;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}

11
client/jest.config.js Normal file
View file

@ -0,0 +1,11 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
setupFiles: ["<rootDir>/test/setup-tests.ts"],
moduleNameMapper: {
"@lib/(.*)": "<rootDir>/src/lib/$1",
"@routes/(.*)": "<rootDir>/src/routes/$1"
},
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/dist/"]
}

View file

@ -4,6 +4,7 @@ declare global {
import config from "@lib/config" import config from "@lib/config"
import { Post, PrismaClient, File, User, Prisma } from "@prisma/client" import { Post, PrismaClient, File, User, Prisma } from "@prisma/client"
export type { User, File, Post } from "@prisma/client"
// we want to update iff they exist the createdAt/updated/expired/deleted items // we want to update iff they exist the createdAt/updated/expired/deleted items
// the input could be an array, in which case we'd check each item in the array // the input could be an array, in which case we'd check each item in the array
@ -34,12 +35,28 @@ const updateDates = (input: any) => {
} }
} }
export const prisma = export const prisma =
global.prisma || global.prisma ||
new PrismaClient({ new PrismaClient({
log: ["query"] log: ["query"]
}) })
// a prisma middleware for capturing the first user and making them an admin
prisma.$use(async (params, next) => {
const result = await next(params)
if (params.model === "User" && params.action === "create") {
const users = await prisma.user.findMany()
if (users.length === 1) {
await prisma.user.update({
where: { id: users[0].id },
data: { role: "admin" }
})
}
}
return result
})
// prisma.$use(async (params, next) => { // prisma.$use(async (params, next) => {
// const result = await next(params) // const result = await next(params)
// return updateDates(result) // return updateDates(result)
@ -47,12 +64,14 @@ export const prisma =
if (process.env.NODE_ENV !== "production") global.prisma = prisma if (process.env.NODE_ENV !== "production") global.prisma = prisma
export type { User, File, Post } from "@prisma/client"
export type PostWithFiles = Post & { export type PostWithFiles = Post & {
files: File[] files: File[]
} }
export type PostWithFilesAndAuthor = PostWithFiles & {
author: User
}
export const getFilesForPost = async (postId: string) => { export const getFilesForPost = async (postId: string) => {
const files = await prisma.file.findMany({ const files = await prisma.file.findMany({
where: { where: {
@ -104,7 +123,7 @@ export const getUserById = async (userId: User["id"]) => {
email: true, email: true,
// displayName: true, // displayName: true,
role: true, role: true,
username: true displayName: true,
} }
}) })
@ -151,13 +170,29 @@ export const createUser = async (
} }
} }
export const getPostById = async (postId: Post["id"], withFiles = false) => { type GetPostByIdOptions = {
withFiles: boolean
withAuthor: boolean
}
export const getPostById = async (
postId: Post["id"],
options?: GetPostByIdOptions
) => {
const post = await prisma.post.findUnique({ const post = await prisma.post.findUnique({
where: { where: {
id: postId id: postId
}, },
include: { include: {
files: withFiles files: options?.withFiles,
author: options?.withAuthor
? {
select: {
id: true,
displayName: true,
}
}
: false
} }
}) })
@ -183,11 +218,29 @@ export const getAllPosts = async ({
return posts as PostWithFiles[] return posts as PostWithFiles[]
} }
export type UserWithPosts = User & {
posts: Post[]
}
export const getAllUsers = async () => {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
// displayName: true,
role: true,
posts: true,
},
})
return users as UserWithPosts[]
}
export const searchPosts = async ( export const searchPosts = async (
query: string, query: string,
{ {
withFiles = false, withFiles = false,
userId, userId
}: { }: {
withFiles?: boolean withFiles?: boolean
userId?: User["id"] userId?: User["id"]

40
client/lib/types.d.ts vendored
View file

@ -1,40 +0,0 @@
export type PostVisibility = "unlisted" | "private" | "public" | "protected"
export type Document = {
title: string
content: string
id: string
}
export type File = {
id: string
title: string
content: string
html: string
createdAt: string
}
type Files = File[]
export type Post = {
id: string
title: string
description: string
visibility: PostVisibility
files?: Files
createdAt: string
users?: User[]
parent?: Pick<Post, "id" | "title" | "visibility" | "createdAt">
expiresAt: Date | string | null
}
type User = {
id: string
username: string
posts?: Post[]
role: "admin" | "user" | ""
createdAt: string
displayName?: string
bio?: string
email?: string
}

View file

@ -9,7 +9,8 @@
"lint": "next lint && prettier --list-different --config .prettierrc '{components,lib,app}/**/*.{ts,tsx}' --write", "lint": "next lint && prettier --list-different --config .prettierrc '{components,lib,app}/**/*.{ts,tsx}' --write",
"analyze": "cross-env ANALYZE=true next build", "analyze": "cross-env ANALYZE=true next build",
"find:unused": "next-unused", "find:unused": "next-unused",
"prisma": "prisma" "prisma": "prisma",
"jest": "jest"
}, },
"dependencies": { "dependencies": {
"@geist-ui/core": "^2.3.8", "@geist-ui/core": "^2.3.8",
@ -23,6 +24,7 @@
"client-zip": "2.2.1", "client-zip": "2.2.1",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cookies-next": "^2.1.1", "cookies-next": "^2.1.1",
"jest": "^29.3.1",
"next": "13.0.3-canary.4", "next": "13.0.3-canary.4",
"next-auth": "^4.16.4", "next-auth": "^4.16.4",
"next-themes": "npm:@wits/next-themes@0.2.7", "next-themes": "npm:@wits/next-themes@0.2.7",
@ -34,7 +36,8 @@
"react-hot-toast": "^2.4.0", "react-hot-toast": "^2.4.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"swr": "1.3.0", "swr": "1.3.0",
"textarea-markdown-editor": "0.1.13" "textarea-markdown-editor": "0.1.13",
"ts-jest": "^29.0.3"
}, },
"devDependencies": { "devDependencies": {
"@next/bundle-analyzer": "12.1.6", "@next/bundle-analyzer": "12.1.6",

View file

@ -27,7 +27,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req }) const session = await getSession({ req })
const id = session?.user?.id const id = session?.user?.id
// get admin from db
const isAdmin = await prisma.user const isAdmin = await prisma.user
.findUnique({ .findUnique({

View file

@ -22,7 +22,10 @@ async function handleGet(req: NextApiRequest, res: NextApiResponse<any>) {
return res.status(400).json({ error: "Missing id" }) return res.status(400).json({ error: "Missing id" })
} }
const post = await getPostById(id, Boolean(files)) const post = await getPostById(id, {
withFiles: Boolean(files),
withAuthor: true
})
if (!post) { if (!post) {
return res.status(404).json({ message: "Post not found" }) return res.status(404).json({ message: "Post not found" })

View file

@ -21,8 +21,7 @@ export default withMethods(["POST"], handler)
async function handlePost(req: NextApiRequest, res: NextApiResponse<any>) { async function handlePost(req: NextApiRequest, res: NextApiResponse<any>) {
try { try {
const session = await unstable_getServerSession(req, res, authOptions) const session = await unstable_getServerSession(req, res, authOptions)
if (!session) { if (!session || !session.user.id) {
console.log("no session")
return res.status(401).json({ error: "Unauthorized" }) return res.status(401).json({ error: "Unauthorized" })
} }
@ -53,12 +52,8 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse<any>) {
password: hashedPassword, password: hashedPassword,
expiresAt: req.body.expiresAt, expiresAt: req.body.expiresAt,
parentId: req.body.parentId, parentId: req.body.parentId,
// authorId: session?.user.id, authorId: session.user.id,
author: {
connect: {
id: session?.user.id
}
}
// files: { // files: {
// connectOrCreate: postFiles.map((file) => ({ // connectOrCreate: postFiles.map((file) => ({
// where: { // where: {

View file

@ -0,0 +1,52 @@
// api/user/[id].ts
import { parseQueryParam } from "@lib/server/parse-query-param"
import { getUserById } from "@lib/server/prisma"
import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from "lib/server/prisma"
import { withMethods } from "@lib/api-middleware/with-methods"
import { getSession } from "next-auth/react"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const id = parseQueryParam(req.query.id)
if (!id) {
return res.status(400).json({ error: "Missing id" })
}
const user = await getUserById(id)
const currUser = (await getSession({ req }))?.user
if (!user) {
return res.status(404).json({ message: "User not found" })
}
if (user.id !== currUser?.id) {
return res.status(403).json({ message: "Unauthorized" })
}
switch (req.method) {
case "PUT":
const { displayName } = req.body
const updatedUser = await prisma.user.update({
where: {
id
},
data: {
displayName
// bio
}
})
return res.json({
id: updatedUser.id,
name: updatedUser.displayName
// bio: updatedUser.bio
})
case "GET":
return res.json(currUser)
default:
return res.status(405).json({ message: "Method not allowed" })
}
}
export default withMethods(["GET", "PUT"], handler)

View file

@ -1,24 +0,0 @@
import { getCurrentUser } from "@lib/server/session"
import { NextApiRequest, NextApiResponse } from "next"
export default async function handler(
_: NextApiRequest,
res: NextApiResponse
): Promise<any> {
const error = () =>
res.status(401).json({
message: "Unauthorized"
})
try {
const user = await getCurrentUser()
if (!user) {
return error()
}
return res.json(user)
} catch (e) {
console.warn(`/api/user/self:`, e)
return error()
}
}

File diff suppressed because it is too large Load diff

View file

@ -88,15 +88,16 @@ model User {
email String? @unique email String? @unique
emailVerified DateTime? emailVerified DateTime?
image String? image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
// custom fields // custom fields
posts Post[] posts Post[]
username String? @unique
role String? @default("user") role String? @default("user")
password String? @db.Text displayName String?
@@map("users") @@map("users")
} }