refactor to SWR and verifyApiUser; personal post search is broken

This commit is contained in:
Max Leiter 2023-01-12 20:50:56 -08:00
parent 6fb81d77b9
commit ba732dcd71
41 changed files with 379 additions and 328 deletions

View file

@ -1,14 +1,9 @@
{
"extends": [
"next/core-web-vitals",
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"root": true,
"ignorePatterns": ["node_modules/", "__tests__/"],
"rules": {
"no-mixed-spaces-and-tabs": ["off"]
}
"plugins": ["@typescript-eslint"],
"extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"],
"ignorePatterns": ["node_modules/", "__tests__/"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "error"
}
}

View file

@ -23,6 +23,9 @@ const nextConfig = {
},
images: {
domains: ["avatars.githubusercontent.com"]
},
env: {
NEXT_PUBLIC_DRIFT_URL: process.env.VERCEL_URL || process.env.DRIFT_URL
}
}

View file

@ -11,7 +11,7 @@ import { useToasts } from "@components/toasts"
import { useRouter, useSearchParams } from "next/navigation"
import Note from "@components/note"
const Auth = ({
function Auth({
page,
requiresServerPassword,
isGithubEnabled
@ -19,7 +19,7 @@ const Auth = ({
page: "signup" | "signin"
requiresServerPassword?: boolean
isGithubEnabled?: boolean
}) => {
}) {
const [serverPassword, setServerPassword] = useState("")
const { setToast } = useToasts()
const signingIn = page === "signin"
@ -38,7 +38,7 @@ const Auth = ({
}
}, [queryParams, setToast])
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const res = await signIn("credentials", {
@ -62,17 +62,17 @@ const Auth = ({
}
}
const handleChangeUsername = (event: React.ChangeEvent<HTMLInputElement>) => {
function handleChangeUsername(event: React.ChangeEvent<HTMLInputElement>) {
setUsername(event.target.value)
}
const handleChangePassword = (event: React.ChangeEvent<HTMLInputElement>) => {
function handleChangePassword(event: React.ChangeEvent<HTMLInputElement>) {
setPassword(event.target.value)
}
const handleChangeServerPassword = (
function handleChangeServerPassword(
event: React.ChangeEvent<HTMLInputElement>
) => {
) {
setServerPassword(event.target.value)
}

View file

@ -2,7 +2,7 @@ import Auth from "../components"
import { getRequiresPasscode } from "src/pages/api/auth/requires-passcode"
import config from "@lib/config"
const getPasscode = async () => {
async function getPasscode() {
return await getRequiresPasscode()
}

View file

@ -8,13 +8,12 @@ import { ChevronDown, Code, File as FileIcon } from "react-feather"
import { Spinner } from "@components/spinner"
import Link from "next/link"
const FileDropdown = ({
files,
loading
function FileDropdown({
files, loading
}: {
files: Pick<PostWithFiles, "files">["files"]
loading?: boolean
}) => {
}) {
if (loading) {
return (
<Popover>

View file

@ -3,6 +3,7 @@ import styles from "./preview.module.css"
import "@styles/markdown.css"
import "@styles/syntax.css"
import { Spinner } from "@components/spinner"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
type Props = {
height?: number | string
@ -11,12 +12,9 @@ type Props = {
title?: string
}
const MarkdownPreview = ({
height = 500,
fileId,
content = "",
title
}: Props) => {
function MarkdownPreview({
height = 500, fileId, content = "", title
}: Props) {
const [preview, setPreview] = useState<string>(content)
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
@ -27,11 +25,11 @@ const MarkdownPreview = ({
const body = fileId
? undefined
: JSON.stringify({
title: title || "",
content: content
})
title: title || "",
content: content
})
const resp = await fetch(path, {
const resp = await fetchWithUser(path, {
method: method,
headers: {
"Content-Type": "application/json"
@ -62,20 +60,18 @@ const MarkdownPreview = ({
export default memo(MarkdownPreview)
export const StaticPreview = ({
preview,
height = 500
export function StaticPreview({
preview, height = 500
}: {
preview: string
height: string | number
}) => {
}) {
return (
<article
className={styles.markdownPreview}
dangerouslySetInnerHTML={{ __html: preview }}
style={{
height
}}
/>
}} />
)
}

View file

@ -2,7 +2,7 @@
import * as RadixTabs from "@radix-ui/react-tabs"
import FormattingIcons from "src/app/(posts)/new/components/edit-document-list/edit-document/formatting-icons"
import { ChangeEvent, useRef } from "react"
import { ChangeEvent, ClipboardEvent, useRef } from "react"
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
import Preview, { StaticPreview } from "../preview"
import styles from "./tabs.module.css"
@ -11,7 +11,7 @@ type Props = RadixTabs.TabsProps & {
isEditing: boolean
defaultTab: "preview" | "edit"
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
onPaste?: (e: any) => void
onPaste?: (e: ClipboardEvent<HTMLTextAreaElement>) => void
title?: string
content?: string
preview?: string

View file

@ -8,7 +8,7 @@ type props = {
description: string
}
const Description = ({ onChange, description }: props) => {
function Description({ onChange, description }: props) {
return (
<div className={styles.description}>
<Input
@ -17,8 +17,7 @@ const Description = ({ onChange, description }: props) => {
label="Description"
maxLength={256}
width="100%"
placeholder="An optional description of your post"
/>
placeholder="An optional description of your post" />
</div>
)
}

View file

@ -14,21 +14,19 @@ import Button from "@components/button"
import clsx from "clsx"
// TODO: clean up
const FormattingIcons = ({
textareaRef,
className
function FormattingIcons({
textareaRef, className
}: {
textareaRef?: RefObject<TextareaMarkdownRef>
className?: string
}) => {
}) {
const formattingActions = useMemo(() => {
const handleBoldClick = () => textareaRef?.current?.trigger("bold")
const handleItalicClick = () => textareaRef?.current?.trigger("italic")
const handleLinkClick = () => textareaRef?.current?.trigger("link")
const handleImageClick = () => textareaRef?.current?.trigger("image")
const handleCodeClick = () => textareaRef?.current?.trigger("code")
const handleListClick = () =>
textareaRef?.current?.trigger("unordered-list")
const handleListClick = () => textareaRef?.current?.trigger("unordered-list")
return [
{
icon: <Bold />,
@ -83,8 +81,7 @@ const FormattingIcons = ({
iconRight={icon}
onMouseDown={(e) => e.preventDefault()}
onClick={action}
buttonType="secondary"
/>
buttonType="secondary" />
</Tooltip>
))}
</div>

View file

@ -1,4 +1,4 @@
import { ChangeEvent, useCallback } from "react"
import { ChangeEvent, ClipboardEvent, useCallback } from "react"
import styles from "./document.module.css"
import Button from "@components/button"
import Input from "@components/input"
@ -12,10 +12,10 @@ type Props = {
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
defaultTab?: "edit" | "preview"
remove?: () => void
onPaste?: (e: any) => void
onPaste?: (e: ClipboardEvent<HTMLTextAreaElement>) => void
}
const Document = ({
function Document({
onPaste,
remove,
title,
@ -23,7 +23,7 @@ const Document = ({
setTitle,
defaultTab = "edit",
handleOnContentChange
}: Props) => {
}: Props) {
const onTitleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) =>
setTitle ? setTitle(event.target.value) : null,
@ -84,6 +84,8 @@ const Document = ({
isEditing={true}
defaultTab={defaultTab}
handleOnContentChange={handleOnContentChange}
// TODO: solve types
// @ts-expect-error Type 'HTMLDivElement' is missing the following properties from type 'HTMLTextAreaElement': autocomplete, cols, defaultValue, dirName, and 26 more
onPaste={onPaste}
title={title}
content={content}

View file

@ -1,8 +1,8 @@
import type { Document } from "../new"
import DocumentComponent from "./edit-document"
import { ChangeEvent, useCallback } from "react"
import { ChangeEvent, useCallback, ClipboardEvent } from "react"
const DocumentList = ({
function DocumentList({
docs,
removeDoc,
updateDocContent,
@ -13,8 +13,8 @@ const DocumentList = ({
updateDocTitle: (i: number) => (title: string) => void
updateDocContent: (i: number) => (content: string) => void
removeDoc: (i: number) => () => void
onPaste: (e: any) => void
}) => {
onPaste?: (e: ClipboardEvent<HTMLTextAreaElement>) => void
}) {
const handleOnChange = useCallback(
(i: number) => (e: ChangeEvent<HTMLTextAreaElement>) => {
updateDocContent(i)(e.target.value)

View file

@ -1,7 +1,7 @@
"use client"
import { useRouter } from "next/navigation"
import { useCallback, useState } from "react"
import { useCallback, useState, ClipboardEvent } from "react"
import generateUUID from "@lib/generate-uuid"
import styles from "./post.module.css"
import EditDocumentList from "./edit-document-list"
@ -17,7 +17,8 @@ import Button from "@components/button"
import Input from "@components/input"
import ButtonDropdown from "@components/button-dropdown"
import { useToasts } from "@components/toasts"
import { useSession } from "next-auth/react"
import { useSessionSWR } from "@lib/use-session-swr"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
const emptyDoc = {
title: "",
@ -31,14 +32,14 @@ export type Document = {
id: string
}
const Post = ({
function Post({
initialPost: stringifiedInitialPost,
newPostParent
}: {
initialPost?: string
newPostParent?: string
}) => {
const session = useSession()
}): JSX.Element | null {
const { isAuthenticated } = useSessionSWR()
const parsedPost = JSON.parse(stringifiedInitialPost || "{}") as PostWithFiles
const initialPost = parsedPost?.id ? parsedPost : null
@ -74,7 +75,7 @@ const Post = ({
parentId?: string
}
) => {
const res = await fetch(url, {
const res = await fetchWithUser(url, {
method: "POST",
headers: {
"Content-Type": "application/json"
@ -115,7 +116,6 @@ const Post = ({
}
setPasswordModalVisible(false)
setSubmitting(true)
let hasErrored = false
@ -164,15 +164,6 @@ const Post = ({
[docs, expiresAt, newPostParent, sendRequest, setToast, title]
)
const onClosePasswordModal = () => {
setPasswordModalVisible(false)
setSubmitting(false)
}
const submitPassword = (password: string) => onSubmit("protected", password)
const onChangeExpiration = (date: Date) => setExpiresAt(date)
const onChangeTitle = useCallback((e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
setTitle(e.target.value)
@ -186,28 +177,47 @@ const Post = ({
[]
)
if (session.status === "unauthenticated") {
if (isAuthenticated === false) {
router.push("/signin")
return null
}
const updateDocTitle = (i: number) => (title: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, title } : doc))
)
function onClosePasswordModal() {
setPasswordModalVisible(false)
setSubmitting(false)
}
const updateDocContent = (i: number) => (content: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, content } : doc))
)
function submitPassword(password: string) {
return onSubmit("protected", password)
}
const removeDoc = (i: number) => () => {
setDocs((docs) => docs.filter((_, index) => i !== index))
function onChangeExpiration(date: Date) {
return setExpiresAt(date)
}
const uploadDocs = (files: Document[]) => {
function updateDocTitle(i: number) {
return (title: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, title } : doc))
)
}
}
function updateDocContent(i: number) {
return (content: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, content } : doc))
)
}
}
function removeDoc(i: number) {
return () => {
setDocs((docs) => docs.filter((_, index) => i !== index))
}
}
function uploadDocs(files: Document[]) {
// if no title is set and the only document is empty,
const isFirstDocEmpty =
docs.length <= 1 && (docs.length ? docs[0].title === "" : true)
@ -224,7 +234,7 @@ const Post = ({
else setDocs((docs) => [...docs, ...files])
}
const onPaste = (e: ClipboardEvent) => {
function onPaste(e: ClipboardEvent<HTMLTextAreaElement>) {
const pastedText = e.clipboardData?.getData("text")
if (pastedText) {
@ -234,32 +244,6 @@ const Post = ({
}
}
const CustomTimeInput = ({
date,
value,
onChange
}: {
date: Date
value: string
onChange: (date: string) => void
}) => (
<input
type="time"
value={value}
onChange={(e) => {
if (!isNaN(date.getTime())) {
onChange(e.target.value || date.toISOString().slice(11, 16))
}
}}
style={{
backgroundColor: "var(--bg)",
border: "1px solid var(--light-gray)",
borderRadius: "var(--radius)"
}}
required
/>
)
return (
<div className={styles.root}>
<Title title={title} onChange={onChangeTitle} />
@ -345,3 +329,31 @@ const Post = ({
}
export default Post
function CustomTimeInput({
date,
value,
onChange
}: {
date: Date
value: string
onChange: (date: string) => void
}) {
return (
<input
type="time"
value={value}
onChange={(e) => {
if (!isNaN(date.getTime())) {
onChange(e.target.value || date.toISOString().slice(11, 16))
}
}}
style={{
backgroundColor: "var(--bg)",
border: "1px solid var(--light-gray)",
borderRadius: "var(--radius)"
}}
required
/>
)
}

View file

@ -20,7 +20,7 @@ type props = {
title?: string
}
const Title = ({ onChange, title }: props) => {
function Title({ onChange, title }: props) {
return (
<div className={styles.title}>
<h1 style={{ margin: 0, padding: 0 }}>Drift</h1>
@ -31,8 +31,7 @@ const Title = ({ onChange, title }: props) => {
label="Title"
className={styles.labelAndInput}
style={{ width: "100%" }}
labelClassName={styles.labelAndInput}
/>
labelClassName={styles.labelAndInput} />
</div>
)
}

View file

@ -3,13 +3,13 @@ import { notFound, redirect } from "next/navigation"
import { getPostById } from "@lib/server/prisma"
import { getSession } from "@lib/server/session"
const NewFromExisting = async ({
async function NewFromExisting({
params
}: {
params: {
id: string
}
}) => {
}) {
const session = await getSession()
if (!session?.user) {
return redirect("/signin")

View file

@ -1,8 +1,8 @@
import NewPost from "src/app/(posts)/new/components/new"
import "./react-datepicker.css"
const New = () => <NewPost />
export default New
export default function New() {
return <NewPost />
}
export const dynamic = "force-static"

View file

@ -5,7 +5,8 @@ import PasswordModal from "@components/password-modal"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { useToasts } from "@components/toasts"
import { useSession } from "next-auth/react"
import { useSessionSWR } from "@lib/use-session-swr"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
type Props = {
setPost: (post: PostWithFilesAndAuthor) => void
@ -16,20 +17,22 @@ type Props = {
const PasswordModalWrapper = ({ setPost, postId, authorId }: Props) => {
const router = useRouter()
const { setToast } = useToasts()
const { data: session, status } = useSession()
const isAuthor =
status === "loading"
? undefined
: session?.user && session?.user?.id === authorId
const { session, isLoading } = useSessionSWR()
const isAuthor = isLoading
? undefined
: session?.user && session?.user?.id === authorId
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
const onSubmit = useCallback(
async (password: string) => {
const res = await fetch(`/api/post/${postId}?password=${password}`, {
method: "GET",
headers: {
"Content-Type": "application/json"
const res = await fetchWithUser(
`/api/post/${postId}?password=${password}`,
{
method: "GET",
headers: {
"Content-Type": "application/json"
}
}
})
)
if (!res.ok) {
setToast({

View file

@ -6,6 +6,7 @@ import { useToasts } from "@components/toasts"
import { Post, User } from "@lib/server/prisma"
import Link from "next/link"
import { useState } from "react"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
import styles from "./table.module.css"
export function UserTable({
@ -25,7 +26,7 @@ export function UserTable({
const deleteUser = async (id: string) => {
try {
const res = await fetch("/api/admin?action=delete-user", {
const res = await fetchWithUser("/api/admin?action=delete-user", {
method: "DELETE",
headers: {
"Content-Type": "application/json"

View file

@ -6,8 +6,9 @@ import ButtonGroup from "@components/button-group"
import Button from "@components/button"
import { useToasts } from "@components/toasts"
import { Spinner } from "@components/spinner"
import { useSession } from "next-auth/react"
import { useRouter } from "next/navigation"
import { useSessionSWR } from "@lib/use-session-swr"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
type Props = {
authorId: string
@ -20,7 +21,7 @@ const VisibilityControl = ({
postId,
visibility: postVisibility
}: Props) => {
const { data: session } = useSession()
const { session } = useSessionSWR()
const isAuthor = session?.user && session?.user?.id === authorId
const [visibility, setVisibility] = useState<string>(postVisibility)
@ -31,13 +32,16 @@ const VisibilityControl = ({
const sendRequest = useCallback(
async (visibility: string, password?: string) => {
const res = await fetch(`/api/post/${postId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ visibility, password })
})
const res = await fetchWithUser(
`/api/post/${postId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ visibility, password })
}
)
if (res.ok) {
const json = await res.json()

View file

@ -4,7 +4,7 @@ import styles from "./header.module.css"
// import useUserData from "@lib/hooks/use-user-data"
import Link from "@components/link"
import { usePathname } from "next/navigation"
import { signOut, useSession } from "next-auth/react"
import { signOut } from "next-auth/react"
import Button from "@components/button"
import clsx from "clsx"
import { useTheme } from "next-themes"
@ -23,6 +23,7 @@ import {
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import buttonStyles from "@components/button/button.module.css"
import { useMemo } from "react"
import { useSessionSWR } from "@lib/use-session-swr"
type Tab = {
name: string
@ -33,10 +34,7 @@ type Tab = {
}
const Header = () => {
const session = useSession()
const isSignedIn = session?.status === "authenticated"
const isAdmin = session?.data?.user?.role === "admin"
const isLoading = session?.status === "loading"
const { isAuthenticated, isAdmin, isLoading, mutate } = useSessionSWR()
const pathname = usePathname()
const { setTheme, resolvedTheme } = useTheme()
@ -97,7 +95,7 @@ const Header = () => {
value: "theme"
})
if (isSignedIn)
if (isAuthenticated)
return [
{
name: "New",
@ -122,10 +120,12 @@ const Header = () => {
name: "Sign Out",
icon: <UserX />,
value: "signout",
onClick: () =>
onClick: () => {
mutate(undefined)
signOut({
callbackUrl: "/"
})
}
}
]
else
@ -150,7 +150,7 @@ const Header = () => {
href: "/signup"
}
]
}, [isAdmin, resolvedTheme, isSignedIn, setTheme])
}, [isAdmin, resolvedTheme, isAuthenticated, setTheme, mutate])
const buttons = pages.map(getButton)

View file

@ -9,6 +9,7 @@ import { useToasts } from "@components/toasts"
import { ListItemSkeleton } from "./list-item-skeleton"
import Link from "@components/link"
import debounce from "lodash.debounce"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
type Props = {
initialPosts: string | PostWithFiles[]
@ -36,6 +37,7 @@ const PostList = ({
const [searchValue, setSearchValue] = useState("")
const [searching, setSearching] = useState(false)
const [posts, setPosts] = useState<PostWithFiles[]>(initialPosts)
const { setToast } = useToasts()
const showSkeleton = skeleton || searching
@ -51,8 +53,8 @@ const PostList = ({
setSearching(true)
async function fetchPosts() {
const res = await fetch(
`/api/post/search?q=${encodeURIComponent(query)}&userId=${userId}`,
const res = await fetchWithUser(
`/api/post/search?q=${encodeURIComponent(query)}`,
{
method: "GET",
headers: {
@ -61,6 +63,7 @@ const PostList = ({
}
)
const json = await res.json()
console.log(json)
setPosts(json)
setSearching(false)
}
@ -79,22 +82,22 @@ const PostList = ({
const deletePost = useCallback(
(postId: string) => async () => {
const res = await fetch(`/api/post/${postId}`, {
const res = await fetchWithUser(`/api/post/${postId}`, {
method: "DELETE"
})
if (!res.ok) {
if (!res?.ok) {
console.error(res)
return
} else {
setPosts((posts) => posts.filter((post) => post.id !== postId))
setPosts((posts) => posts?.filter((post) => post.id !== postId))
setToast({
message: "Post deleted",
type: "success"
})
}
},
[setToast]
[setPosts, setToast]
)
return (
@ -118,7 +121,7 @@ const PostList = ({
<ListItemSkeleton />
</ul>
)}
{!showSkeleton && posts?.length > 0 && (
{!showSkeleton && posts && posts.length > 0 ? (
<div>
<ul>
{posts.map((post) => {
@ -134,7 +137,7 @@ const PostList = ({
})}
</ul>
</div>
)}
) : null}
</div>
)
}

View file

@ -66,7 +66,7 @@ const ListItem = ({
}
return (
<FadeIn as="li" key={post.id}>
<FadeIn key={post.id}>
<li>
<Card style={{ overflowY: "scroll" }}>
<>

View file

@ -16,17 +16,7 @@ const TOKENS_ENDPOINT = "/api/user/tokens"
export function useApiTokens({ userId, initialTokens }: UseApiTokens) {
const { data, mutate, error, isLoading } = useSWR<SerializedApiToken[]>(
"/api/user/tokens?userId=" + userId,
async (url: string) => {
return fetch(url).then(async (res) => {
const data = await res.json()
if (data.error) {
throw new Error(data.error)
}
return data
})
},
userId ? "/api/user/tokens?userId=" + userId : null,
{
refreshInterval: 10000,
fallbackData: initialTokens

View file

@ -13,7 +13,8 @@ interface RootLayoutProps {
export default async function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en" className={inter.variable}>
// suppressHydrationWarning is required because of next-themes
<html lang="en" className={inter.variable} suppressHydrationWarning>
<head />
<body>
<Toasts />

View file

@ -0,0 +1,12 @@
import { getSession } from "next-auth/react"
/**
* a fetch wrapper that adds `userId={userId}` to the query string
*/
export async function fetchWithUser(url: string, options: RequestInit = {}) {
// TODO: figure out if this extra network call hurts performance
const session = await getSession()
const newUrl = new URL(url, process.env.NEXT_PUBLIC_DRIFT_URL)
newUrl.searchParams.append("userId", session?.user.id || "")
return fetch(newUrl.toString(), options)
}

View file

@ -6,7 +6,7 @@ import { getAllPosts, Post } from "@lib/server/prisma"
import PostList, { NoPostsFound } from "@components/post-list"
import { Suspense } from "react"
const getWelcomeData = async () => {
export async function getWelcomeData() {
const welcomeContent = await getWelcomeContent()
return welcomeContent
}

View file

@ -3,15 +3,29 @@
import * as RadixTooltip from "@radix-ui/react-tooltip"
import { SessionProvider } from "next-auth/react"
import { ThemeProvider } from "next-themes"
import { SWRConfig } from "swr"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<RadixTooltip.Provider delayDuration={200}>
<ThemeProvider enableSystem defaultTheme="dark">
{children}
</ThemeProvider>
</RadixTooltip.Provider>
<SWRConfig
value={{
fetcher: async (url: string) => {
const data = await fetch(url).then((res) => res.json())
if (data.error) {
throw new Error(data.error)
}
return data
},
keepPreviousData: true
}}
>
<RadixTooltip.Provider delayDuration={200}>
<ThemeProvider enableSystem defaultTheme="dark">
{children}
</ThemeProvider>
</RadixTooltip.Provider>
</SWRConfig>
</SessionProvider>
)
}

View file

@ -10,9 +10,9 @@ import {
useApiTokens
} from "src/app/hooks/swr/use-api-tokens"
import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
import { useSession } from "next-auth/react"
import { useState } from "react"
import styles from "./api-keys.module.css"
import { useSessionSWR } from "@lib/use-session-swr"
// need to pass in the accessToken
const APIKeys = ({
@ -20,10 +20,10 @@ const APIKeys = ({
}: {
tokens?: SerializedApiToken[]
}) => {
const session = useSession()
const { session } = useSessionSWR()
const { setToast } = useToasts()
const { data, error, createToken, expireToken } = useApiTokens({
userId: session.data?.user.id,
userId: session?.user?.id,
initialTokens
})

View file

@ -4,21 +4,27 @@ import Button from "@components/button"
import Input from "@components/input"
import Note from "@components/note"
import { useToasts } from "@components/toasts"
import { useSession } from "next-auth/react"
import { useSessionSWR } from "@lib/use-session-swr"
import { useEffect, useState } from "react"
import styles from "./profile.module.css"
import useSWR from "swr"
import { User } from "@prisma/client"
const Profile = () => {
const { data: session } = useSession()
const [name, setName] = useState<string>(session?.user.name || "")
function Profile() {
const { session } = useSessionSWR()
console.log(session)
const { data: userData } = useSWR<User>(
session?.user?.id ? `/api/user/${session?.user?.id}` : null
)
const [name, setName] = useState<string>(userData?.displayName || "")
const [submitting, setSubmitting] = useState<boolean>(false)
const { setToast } = useToasts()
useEffect(() => {
if (!name) {
setName(session?.user.name || "")
if (!name && userData?.displayName) {
setName(userData?.displayName)
}
}, [name, session])
}, [name, userData?.displayName])
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
@ -39,7 +45,7 @@ const Profile = () => {
displayName: name
}
const res = await fetch(`/api/user/${session?.user.id}`, {
const res = await fetch(`/api/user/${session?.user?.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
@ -64,7 +70,6 @@ const Profile = () => {
/* if we have their email, they signed in with OAuth */
// const imageViaOauth = Boolean(session?.user.email)
// const TooltipComponent = ({ children }: { children: React.ReactNode }) =>
// imageViaOauth ? (
// <Tooltip content="Change your profile image on your OAuth provider">
@ -73,7 +78,6 @@ const Profile = () => {
// ) : (
// <>{children}</>
// )
return (
<>
<Note type="warning">
@ -106,39 +110,39 @@ const Profile = () => {
/>
</div>
{/* <div>
<label htmlFor="image">User Avatar</label>
{user.image ? (
<Input
id="image"
type="file"
width={"100%"}
placeholder="my image"
disabled
aria-label="Image"
src={user.image}
/>
) : (
<UserIcon />
)}
<TooltipComponent>
<div className={styles.upload}>
<input
type="file"
disabled={imageViaOauth}
className={styles.uploadInput}
/>
<Button
type="button"
disabled={imageViaOauth}
width="100%"
className={styles.uploadButton}
aria-hidden="true"
>
Upload
</Button>
</div>
</TooltipComponent>
</div> */}
<label htmlFor="image">User Avatar</label>
{user.image ? (
<Input
id="image"
type="file"
width={"100%"}
placeholder="my image"
disabled
aria-label="Image"
src={user.image}
/>
) : (
<UserIcon />
)}
<TooltipComponent>
<div className={styles.upload}>
<input
type="file"
disabled={imageViaOauth}
className={styles.uploadInput}
/>
<Button
type="button"
disabled={imageViaOauth}
width="100%"
className={styles.uploadButton}
aria-hidden="true"
>
Upload
</Button>
</div>
</TooltipComponent>
</div> */}
<Button type="submit" loading={submitting}>
Submit

View file

@ -1,12 +1,19 @@
import type { GetServerSidePropsContext } from "next"
import { unstable_getServerSession } from "next-auth/next"
import { authOptions } from "./auth"
export async function getSession() {
return await unstable_getServerSession(authOptions)
type Params = {
req: GetServerSidePropsContext["req"]
res: GetServerSidePropsContext["res"]
}
export async function getCurrentUser() {
const session = await getSession()
export async function getSession(params?: Params) {
if (!params) return await unstable_getServerSession(authOptions)
return await unstable_getServerSession(params.req, params.res, authOptions)
}
export async function getCurrentUser(params?: Params) {
const session = await getSession(params)
return session?.user
}

View file

@ -1,8 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next"
import { unstable_getServerSession } from "next-auth"
import { authOptions } from "./auth"
import { parseQueryParam } from "./parse-query-param"
import { prisma } from "./prisma"
import { getCurrentUser } from "./session"
/**
* verifyApiUser checks for a `userId` param. If it exists, it checks that the
@ -23,12 +22,9 @@ export const verifyApiUser = async (
return parseAndCheckAuthToken(req)
}
const session = await unstable_getServerSession(req, res, authOptions)
if (!session) {
return null
}
const user = await getCurrentUser({ req, res })
if (session.user.id !== userId) {
if (user?.id !== userId) {
return null
}

View file

@ -0,0 +1,25 @@
import { Session } from "next-auth"
import useSWR from "swr"
export function useSessionSWR() {
const {
data: session,
error,
isLoading,
isValidating,
mutate
} = useSWR<Session>("/api/auth/session")
return {
session,
error,
isLoading,
isValidating,
mutate,
/** undefined while loading */
isAuthenticated: session?.user?.id ? true : isLoading ? undefined : false,
/** undefined while loading */
isAdmin:
session?.user?.id === "admin" ? true : isLoading ? undefined : false
}
}

View file

@ -1,8 +1,8 @@
import { withMethods } from "@lib/api-middleware/with-methods"
import { parseQueryParam } from "@lib/server/parse-query-param"
import { getCurrentUser } from "@lib/server/session"
import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from "src/lib/server/prisma"
import { getSession } from "next-auth/react"
import { deleteUser } from "../user/[id]"
const actions = [
@ -24,19 +24,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return
}
const session = await getSession({ req })
const id = session?.user?.id
const isAdmin = await prisma.user
.findUnique({
where: {
id
},
select: {
role: true
}
})
.then((user) => user?.role === "admin")
const user = await getCurrentUser({ req, res })
const isAdmin = user?.role === "admin"
if (!isAdmin) {
return res.status(403).json({ error: "Not authorized" })

View file

@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from "src/lib/server/prisma"
import { parseQueryParam } from "@lib/server/parse-query-param"
import { withMethods } from "@lib/api-middleware/with-methods"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
const { id, download } = req.query
@ -30,4 +31,4 @@ const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
res.end()
}
export default getRawFile
export default withMethods(["GET"], getRawFile)

View file

@ -1,3 +1,4 @@
import { withMethods } from "@lib/api-middleware/with-methods"
import { NextApiRequest, NextApiResponse } from "next"
const handler = async (_: NextApiRequest, res: NextApiResponse) => {
@ -6,4 +7,4 @@ const handler = async (_: NextApiRequest, res: NextApiResponse) => {
})
}
export default handler
export default withMethods(["GET"], handler)

View file

@ -2,17 +2,10 @@ import { withMethods } from "@lib/api-middleware/with-methods"
import { parseQueryParam } from "@lib/server/parse-query-param"
import { getPostById } from "@lib/server/prisma"
import type { NextApiRequest, NextApiResponse } from "next"
import { getSession } from "next-auth/react"
import { prisma } from "src/lib/server/prisma"
import * as crypto from "crypto"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") return handleGet(req, res)
else if (req.method === "PUT") return handlePut(req, res)
else if (req.method === "DELETE") return handleDelete(req, res)
}
export default withMethods(["GET", "PUT", "DELETE"], handler)
import { getSession } from "@lib/server/session"
import { verifyApiUser } from "@lib/server/verify-api-user"
async function handleGet(req: NextApiRequest, res: NextApiResponse<unknown>) {
const id = parseQueryParam(req.query.id)
@ -39,10 +32,10 @@ async function handleGet(req: NextApiRequest, res: NextApiResponse<unknown>) {
res.setHeader("Cache-Control", "s-maxage=1, stale-while-revalidate")
}
const session = await getSession({ req })
const userId = await verifyApiUser(req, res)
// the user can always go directly to their own post
if (session?.user.id === post.authorId) {
if (userId === post.authorId) {
return res.json({
post: post,
password: undefined
@ -92,9 +85,8 @@ async function handlePut(req: NextApiRequest, res: NextApiResponse<unknown>) {
return res.status(404).json({ message: "Post not found" })
}
const session = await getSession({ req })
const isAuthor = session?.user.id === post.authorId
const session = await getSession({ req, res })
const isAuthor = session?.user?.id === post.authorId
if (!isAuthor) {
return res.status(403).json({ message: "Unauthorized" })
@ -142,10 +134,10 @@ async function handleDelete(
return res.status(404).json({ message: "Post not found" })
}
const session = await getSession({ req })
const session = await getSession({ req, res })
const isAuthor = session?.user.id === post.authorId
const isAdmin = session?.user.role === "admin"
const isAuthor = session?.user?.id === post.authorId
const isAdmin = session?.user?.role === "admin"
if (!isAuthor && !isAdmin) {
return res.status(403).json({ message: "Unauthorized" })
@ -162,3 +154,11 @@ async function handleDelete(
res.json({ message: "Post deleted" })
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") return handleGet(req, res)
else if (req.method === "PUT") return handlePut(req, res)
else if (req.method === "DELETE") return handleDelete(req, res)
}
export default withMethods(["GET", "PUT", "DELETE"], handler)

View file

@ -1,36 +1,19 @@
import { withMethods } from "@lib/api-middleware/with-methods"
import { authOptions } from "@lib/server/auth"
import { prisma } from "@lib/server/prisma"
import { NextApiRequest, NextApiResponse } from "next"
import { unstable_getServerSession } from "next-auth/next"
import { File } from "@lib/server/prisma"
import * as crypto from "crypto"
import { getHtmlFromFile } from "@lib/server/get-html-from-drift-file"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return await handlePost(req, res)
}
export default withMethods(["POST"], handler)
import { verifyApiUser } from "@lib/server/verify-api-user"
async function handlePost(req: NextApiRequest, res: NextApiResponse<unknown>) {
try {
const session = await unstable_getServerSession(req, res, authOptions)
if (!session || !session.user.id) {
const userId = await verifyApiUser(req, res)
if (!userId) {
return res.status(401).json({ error: "Unauthorized" })
}
const user = await prisma.user.findUnique({
where: {
id: session.user.id
}
})
if (!user) {
return res.status(404).json({ error: "User not found" })
}
const files = req.body.files as (Omit<File, "content" | "html"> & {
content: string
html: string
@ -71,7 +54,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse<unknown>) {
password: hashedPassword,
expiresAt: req.body.expiresAt,
parentId: req.body.parentId,
authorId: session.user.id,
authorId: userId,
files: {
create: files.map((file) => {
return {
@ -86,7 +69,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse<unknown>) {
fileHtml[files.indexOf(file)] as string,
"utf-8"
),
userId: session.user.id
userId
}
})
}
@ -100,3 +83,9 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse<unknown>) {
return res.status(500).json(error)
}
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return await handlePost(req, res)
}
export default withMethods(["POST"], handler)

View file

@ -1,39 +1,45 @@
import { withMethods } from "@lib/api-middleware/with-methods"
import { authOptions } from "@lib/server/auth"
import { parseQueryParam } from "@lib/server/parse-query-param"
import { searchPosts, ServerPostWithFiles } from "@lib/server/prisma"
import { searchPosts } from "@lib/server/prisma"
import { verifyApiUser } from "@lib/server/verify-api-user"
import { NextApiRequest, NextApiResponse } from "next"
import { unstable_getServerSession } from "next-auth"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { q, userId } = req.query
const query = req.query
const q = parseQueryParam(query.q)
const publicSearch = parseQueryParam(query.public)
const searchQuery = parseQueryParam(q)
const session = await unstable_getServerSession(req, res, authOptions)
const query = parseQueryParam(q)
const user = parseQueryParam(userId)
if (!query) {
console.log(
"searchQuery",
searchQuery,
"publicSearch",
publicSearch,
"userId",
query.userId
)
if (!searchQuery) {
res.status(400).json({ error: "Invalid query" })
return
}
try {
let posts: ServerPostWithFiles[]
if (session?.user.id === user || session?.user.role === "admin") {
posts = await searchPosts(query, {
userId: user
})
} else {
posts = await searchPosts(query, {
userId: user,
publicOnly: true
})
if (publicSearch) {
const posts = await searchPosts(searchQuery)
return res.json(posts)
} else {
const userId = await verifyApiUser(req, res)
if (!userId) {
res.status(401).json({ error: "Unauthorized" })
return
}
res.status(200).json(posts)
} catch (err) {
console.error(err)
res.status(500).json({ error: "Internal server error" })
const posts = await searchPosts(searchQuery, {
userId,
withFiles: true,
publicOnly: false
})
return res.json(posts)
}
}

View file

@ -1,13 +1,11 @@
// https://beta.nextjs.org/docs/data-fetching/revalidating#on-demand-revalidation
import { withMethods } from "@lib/api-middleware/with-methods"
import config from "@lib/config"
import { parseQueryParam } from "@lib/server/parse-query-param"
import type { NextApiRequest, NextApiResponse } from "next"
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
async function handler(req: NextApiRequest, res: NextApiResponse) {
// TODO: create a new secret?
if (req.query.secret !== config.nextauth_secret) {
return res.status(401).json({ message: "Invalid token" })
@ -26,3 +24,5 @@ export default async function handler(
return res.status(500).send("Error revalidating")
}
}
export default withMethods(["GET"], handler)

View file

@ -58,8 +58,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
}
export default withMethods(["GET", "PUT", "DELETE"], handler)
/**
* @description Deletes a user and all of their posts, files, and accounts
* @warning This function does not perform any authorization checks
@ -83,3 +81,5 @@ export async function deleteUser(id: string | undefined) {
}
})
}
export default withMethods(["GET", "PUT", "DELETE"], handler)

View file

@ -2,11 +2,9 @@ import { parseQueryParam } from "@lib/server/parse-query-param"
import { NextApiRequest, NextApiResponse } from "next"
import { createApiToken, prisma } from "@lib/server/prisma"
import { verifyApiUser } from "@lib/server/verify-api-user"
import { withMethods } from "@lib/api-middleware/with-methods"
export default async function handle(
req: NextApiRequest,
res: NextApiResponse
) {
async function handler(req: NextApiRequest, res: NextApiResponse) {
const userId = await verifyApiUser(req, res)
if (!userId) {
return res.status(400).json({ error: "Missing userId or auth token" })
@ -55,3 +53,5 @@ export default async function handle(
return res.status(405).end()
}
}
export default withMethods(["GET", "POST", "DELETE"], handler)

View file

@ -1,11 +1,12 @@
// a nextjs api handerl
import { withMethods } from "@lib/api-middleware/with-methods"
import config from "@lib/config"
import { getHtmlFromFile } from "@lib/server/get-html-from-drift-file"
import { NextApiRequest, NextApiResponse } from "next"
export const getWelcomeContent = async () => {
export async function getWelcomeContent() {
const introContent = config.welcome_content
const introTitle = config.welcome_title
@ -19,7 +20,7 @@ export const getWelcomeContent = async () => {
}
}
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
async function handler(_: NextApiRequest, res: NextApiResponse) {
const welcomeContent = await getWelcomeContent()
if (!welcomeContent) {
return res.status(500).json({ error: "Missing welcome content" })
@ -27,3 +28,5 @@ export default async function handler(_: NextApiRequest, res: NextApiResponse) {
return res.json(welcomeContent)
}
export default withMethods(["GET"], handler)