diff --git a/package.json b/package.json index 616d5ee..6baad17 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@prisma/client": "4.10.1", + "@tanstack/react-query": "^4.24.9", "axios": "^1.1.2", "bcrypt": "^5.1.0", "configcat-node": "^8.0.0", diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx new file mode 100644 index 0000000..7e51e00 --- /dev/null +++ b/src/components/LoginForm.tsx @@ -0,0 +1,104 @@ +import { useRouter } from "next/router"; + +import axios from "axios"; + +import { FC, FormEvent, useCallback, useState } from "react"; + +import { LoginCredentials } from "@models/login"; +import { Response } from "@models/response"; + +import { parseAxiosError, parseAxiosResponse } from "@utils/fetch"; +import { parseUserInputError } from "@utils/errors"; + +const LoginForm: FC = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); + + const [error, setError] = useState(null); + + const router = useRouter(); + + const login = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + + const parseUserInputResult = LoginCredentials.safeParse({ + username, + password, + rememberMe + }); + + if (!parseUserInputResult.success) { + setError(parseUserInputError(parseUserInputResult.error.message)); + return; + } + + const response: Response = await axios + .post("/api/login", parseUserInputResult.data) + .then(parseAxiosResponse) + .catch(parseAxiosError); + + if (response.ok) { + router.push("/blog"); + } else { + setError(JSON.stringify(response.error)); + } + }, + [username, password, rememberMe, router] + ); + + return ( +
+
+ + setUsername(e.target.value)} + name="username" + type="email" + id="email" + placeholder="mail@example.com" + /> + + setPassword(e.target.value)} + minLength={8} + maxLength={128} + name="password" + type="password" + id="password" + /> + + {error !== null && ( +
+
+ )} + +
+ + ); +}; + +export default LoginForm; diff --git a/src/components/SignupForm.tsx b/src/components/SignupForm.tsx new file mode 100644 index 0000000..abbd4e9 --- /dev/null +++ b/src/components/SignupForm.tsx @@ -0,0 +1,111 @@ +import axios from "axios"; + +import { FC, FormEvent, useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +import { SignupCredentials } from "@models/signup"; +import { Response } from "@models/response"; + +import { parseAxiosError, parseAxiosResponse } from "@utils/fetch"; +import { parseUserInputError } from "@utils/errors"; + +const SignupForm: FC = () => { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [username, setUsername] = useState(""); + + const [error, setError] = useState(null); + + const router = useRouter(); + + const signup = useCallback( + async (e: FormEvent): Promise => { + e.preventDefault(); + + const parseUserInputResult = SignupCredentials.safeParse({ + email, + password, + name: username + }); + + if (!parseUserInputResult.success) { + setError(parseUserInputError(parseUserInputResult.error.message)); + return; + } + + const response: Response = await axios + .post("/api/signup", parseUserInputResult.data) + .then(parseAxiosResponse) + .catch(parseAxiosError); + + if (response.ok) { + router.push("/blog"); + } else { + setError(JSON.stringify(response.error)); + } + }, + [email, password, username, router] + ); + + return ( +
+
+ + setEmail(e.target.value)} + name="email" + type="email" + id="email" + placeholder="mail@example.com" + /> + + setPassword(e.target.value)} + minLength={8} + maxLength={128} + name="password" + type="password" + id="password" + /> + + + setUsername(e.target.value)} + maxLength={32} + name="name" + type="text" + id="name" + /> + + {error !== null && ( +
+
+ )} + + +
+
+ ); +}; + +export default SignupForm; diff --git a/src/models/responses.ts b/src/models/git/responses.ts similarity index 100% rename from src/models/responses.ts rename to src/models/git/responses.ts diff --git a/src/models/login.ts b/src/models/login.ts index 238dc7a..14dc685 100644 --- a/src/models/login.ts +++ b/src/models/login.ts @@ -3,5 +3,5 @@ import z from "zod"; export const LoginCredentials = z.object({ username: z.string().email(), password: z.string().min(8).max(128), - rememberMe: z.literal("on").optional() + rememberMe: z.boolean() }); diff --git a/src/models/post.ts b/src/models/post.ts index 520e108..789af14 100644 --- a/src/models/post.ts +++ b/src/models/post.ts @@ -2,7 +2,7 @@ import z from "zod"; export const Post = z.object({ title: z.string(), - content: z.string(), + content: z.string().min(100), tags: z.string(), - published: z.literal("on").optional() + publish: z.boolean() }); diff --git a/src/models/response.ts b/src/models/response.ts new file mode 100644 index 0000000..5361cbb --- /dev/null +++ b/src/models/response.ts @@ -0,0 +1,27 @@ +import z from "zod"; + +interface Error { + ok: false; + error?: unknown; +} + +export const ErrorModel: z.ZodType = z.object({ + ok: z.literal(false), + error: z.unknown() +}); + +interface Ok { + ok: true; + data?: unknown; +} + +export const OkModel: z.ZodType = z.object({ + ok: z.literal(true), + data: z.unknown() +}); + +export type Response = Ok | Error; + +const ResponseModel: z.ZodType = ErrorModel.or(OkModel); + +export default ResponseModel; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index eedfd60..b2d74dd 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,3 +1,5 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + import "@styles/globals.scss"; import SEO from "../next-seo.config"; @@ -7,11 +9,15 @@ import { ThemeProvider } from "next-themes"; import type { AppProps } from "next/app"; +const queryClient = new QueryClient(); + const App = ({ Component, pageProps }: AppProps): JSX.Element => ( <> - + + + ); diff --git a/src/pages/blog/admin.module.scss b/src/pages/admin.module.scss similarity index 100% rename from src/pages/blog/admin.module.scss rename to src/pages/admin.module.scss diff --git a/src/pages/blog/admin.tsx b/src/pages/admin.tsx similarity index 95% rename from src/pages/blog/admin.tsx rename to src/pages/admin.tsx index af0e4a3..9c4a68a 100644 --- a/src/pages/blog/admin.tsx +++ b/src/pages/admin.tsx @@ -24,7 +24,7 @@ const AdminPage: NextPage<{

Welcome {user.name}

-
+

Users

@@ -49,7 +49,7 @@ const AdminPage: NextPage<{
-
+

Posts

diff --git a/src/pages/api/blog/new.ts b/src/pages/api/blog/new.ts index 87343a1..1fa08d4 100644 --- a/src/pages/api/blog/new.ts +++ b/src/pages/api/blog/new.ts @@ -2,11 +2,12 @@ import { NextApiHandler } from "next"; import { methodNotAllowed, unauthorized } from "@utils/errors"; import { withIronSession } from "@utils/session"; -import { Post } from "@models/post"; - import prisma from "@utils/prisma"; -const handle: NextApiHandler = async (req, res) => { +import { Response } from "@models/response"; +import { Post } from "@models/post"; + +const handle: NextApiHandler = async (req, res) => { if (req.method?.toUpperCase() != "POST") { res.status(405).json(methodNotAllowed); return; @@ -26,23 +27,24 @@ const handle: NextApiHandler = async (req, res) => { return; } - const data = postData.data; - - await prisma.user.update({ - where: { id: user.id }, - data: { - posts: { - create: { - ...data, - published: data.published !== undefined, - tags: data.tags.trim().split(" "), - content: data.content.trim() + const { publish, ...data } = postData.data; + + await prisma.user + .update({ + where: { id: user.id }, + data: { + posts: { + create: { + ...data, + published: publish, + tags: data.tags.trim().split(" "), + content: data.content.trim() + } } } - } - }); - - res.redirect("/blog"); + }) + .then(() => res.json({ ok: true, data: "Successfully created new post" })) + .catch((error) => res.status(500).json({ ok: false, error })); }; export default withIronSession(handle); diff --git a/src/pages/api/blog/login.ts b/src/pages/api/login.ts similarity index 83% rename from src/pages/api/blog/login.ts rename to src/pages/api/login.ts index 10af76e..fba72d4 100644 --- a/src/pages/api/blog/login.ts +++ b/src/pages/api/login.ts @@ -2,13 +2,14 @@ import bcrypt from "bcrypt"; import { NextApiHandler } from "next"; import { LoginCredentials } from "@models/login"; +import { Response } from "@models/response"; import { withIronSession } from "@utils/session"; import { methodNotAllowed, unauthorized } from "@utils/errors"; import prisma from "@utils/prisma"; -const handle: NextApiHandler = async (req, res) => { - if (req.method?.toUpperCase() != "POST") { +const handle: NextApiHandler = async (req, res) => { + if (req.method?.toUpperCase() !== "POST") { res.status(405).json(methodNotAllowed); return; } @@ -40,13 +41,14 @@ const handle: NextApiHandler = async (req, res) => { if (!isCorrect) { res.status(401).json(unauthorized); + return; } req.session.user = user; await req.session.save(); - res.redirect("/blog"); + res.json({ ok: true, data: "Login successfull" }); }; export default withIronSession(handle); diff --git a/src/pages/api/blog/logout.ts b/src/pages/api/logout.ts similarity index 75% rename from src/pages/api/blog/logout.ts rename to src/pages/api/logout.ts index f4bd35d..7ab36aa 100644 --- a/src/pages/api/blog/logout.ts +++ b/src/pages/api/logout.ts @@ -2,8 +2,9 @@ import { NextApiHandler } from "next"; import { withIronSession } from "@utils/session"; import { methodNotAllowed, unauthorized } from "@utils/errors"; +import { Response } from "@models/response"; -const handle: NextApiHandler = (req, res) => { +const handle: NextApiHandler = (req, res) => { if (req.method?.toUpperCase() != "GET") { res.status(405).json(methodNotAllowed); return; @@ -20,7 +21,7 @@ const handle: NextApiHandler = (req, res) => { req.session.user = undefined; - res.redirect("/blog"); + res.json({ ok: true, data: "Logout successfull" }); }; export default withIronSession(handle); diff --git a/src/pages/api/settings.ts b/src/pages/api/settings.ts new file mode 100644 index 0000000..5414381 --- /dev/null +++ b/src/pages/api/settings.ts @@ -0,0 +1,17 @@ +import { Response } from "@models/response"; + +import { NextApiHandler } from "next"; + +import { registrationIsEnabled } from "@utils/config"; +import { methodNotAllowed } from "@utils/errors"; + +const handler: NextApiHandler = (req, res) => { + if (req.method?.toUpperCase() !== "GET") { + res.status(405).json(methodNotAllowed); + return; + } + + res.json({ ok: true, data: { registrationIsEnabled } }); +}; + +export default handler; diff --git a/src/pages/api/blog/signup.ts b/src/pages/api/signup.ts similarity index 89% rename from src/pages/api/blog/signup.ts rename to src/pages/api/signup.ts index 9264f1d..5c129c8 100644 --- a/src/pages/api/blog/signup.ts +++ b/src/pages/api/signup.ts @@ -5,11 +5,13 @@ import { NextApiHandler } from "next"; import prisma from "@utils/prisma"; import { SignupCredentials } from "@models/signup"; +import { Response } from "@models/response"; + import { withIronSession } from "@utils/session"; import { methodNotAllowed } from "@utils/errors"; import { registrationIsEnabled, saltRoundsForPassword } from "@utils/config"; -const handle: NextApiHandler = async (req, res) => { +const handle: NextApiHandler = async (req, res) => { if (!registrationIsEnabled) { res .status(403) @@ -54,7 +56,7 @@ const handle: NextApiHandler = async (req, res) => { await req.session.save(); - res.redirect("/blog"); + res.json({ ok: true, data: "Signup successfull" }); }) .catch((error) => res.status(500).json({ ok: false, error })); }; diff --git a/src/pages/blog/login.tsx b/src/pages/blog/login.tsx deleted file mode 100644 index 22674da..0000000 --- a/src/pages/blog/login.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { GetServerSideProps, NextPage } from "next"; -import { NextSeo } from "next-seo"; - -import Layout from "@components/Layout"; - -import multipleClassNames from "@utils/multipleClassNames"; - -import styles from "./login.module.scss"; -import { registrationIsEnabled } from "@utils/config"; - -const Login: NextPage<{ registrationEnabled: boolean }> = ({ - registrationEnabled -}) => { - return ( - - -
-
-
-

Login to blog

-
-
- - - - - - -
- -
- {registrationEnabled && ( - <> -
-
-

Create new account

-
-
- - - - - - - - - -
- -
- - )} -
-
- - ); -}; - -export const getServerSideProps: GetServerSideProps = async () => { - return { props: { registrationEnabled: registrationIsEnabled } }; -}; - -export default Login; diff --git a/src/pages/blog/new.tsx b/src/pages/blog/new.tsx index ea6d0db..e1676e3 100644 --- a/src/pages/blog/new.tsx +++ b/src/pages/blog/new.tsx @@ -1,15 +1,64 @@ +import axios from "axios"; + +import { FormEvent, useCallback, useState } from "react"; + import { NextPage } from "next"; import { NextSeo } from "next-seo"; +import { useRouter } from "next/router"; import { User } from "@prisma/client"; import Layout from "@components/Layout"; import { withSessionSsr } from "@utils/session"; +import { parseAxiosError, parseAxiosResponse } from "@utils/fetch"; +import { parseUserInputError } from "@utils/errors"; + +import { Post } from "@models/post"; +import { Response } from "@models/response"; import styles from "./new.module.scss"; const NewPostPage: NextPage<{ user: User }> = ({ user }) => { + const [title, setTitle] = useState(""); + const [tags, setTags] = useState(""); + const [content, setContent] = useState(""); + const [publish, setPublish] = useState(false); + + const [error, setError] = useState(null); + + const router = useRouter(); + + const createPost = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + + const parseUserInputResult = Post.safeParse({ + title, + tags, + content, + publish + }); + + if (!parseUserInputResult.success) { + setError(parseUserInputError(parseUserInputResult.error.message)); + return; + } + + const response: Response = await axios + .post("/api/blog/new", parseUserInputResult.data) + .then(parseAxiosResponse) + .catch(parseAxiosError); + + if (response.ok) { + router.push("/blog"); + } else { + setError(JSON.stringify(response.error)); + } + }, + [title, tags, content, publish, router] + ); + return ( @@ -19,12 +68,14 @@ const NewPostPage: NextPage<{ user: User }> = ({ user }) => {

Create new post

Logged in as {user.name}
-
+
setTitle(e.target.value)} required className="form-input" name="title" @@ -37,6 +88,8 @@ const NewPostPage: NextPage<{ user: User }> = ({ user }) => { Tags setTags(e.target.value)} required className="form-input" name="tags" @@ -49,21 +102,39 @@ const NewPostPage: NextPage<{ user: User }> = ({ user }) => { Content