diff --git a/.gitignore b/.gitignore index fd3dbb5..c7de9b4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +#secret +.env diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..c34e9f3 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,21 @@ +pipeline { +agent any + environment{ + JWT_SECRET=credentials('JWT_SECRET') + DATABASE_URL=credentials('DATABASE_URL') + } + stages{ + stage('build'){ + steps{ + sh 'npm install' + sh 'npm run build' + sh ' tar -C .next -cvf subman.tar.gz standalone ' + } + } + stage('deploy'){ + steps{ + sshPublisher(publishers: [sshPublisherDesc(configName: 'Demos', transfers: [sshTransfer(cleanRemote: false, excludes: '', execCommand: 'ssh-uploads/subman/upgrade.sh', execTimeout: 120000, flatten: false, makeEmptyDirs: false, noDefaultExcludes: false, patternSeparator: '[, ]+', remoteDirectory: 'ssh-uploads/subman/', remoteDirectorySDF: false, removePrefix: '', sourceFiles: 'subman.tar.gz')], usePromotionTimestamp: false, useWorkspaceInPromotion: false, verbose: false)]) + } + } + } +} diff --git a/README.md b/README.md index c403366..8f3782c 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,18 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# Subman +## A self-hosted literary submission manager -## Getting Started +I developed this project as a demonstration of my full-stack development abilities, utilizing: -First, run the development server: +- Nextjs +- Tailwind +- heavily customised Shadcn components +- an Sqlite database with Prisma ORM as intermediary -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +My previous attempt at this project was [a Nodejs server](https://projects.ajstepien.xyz/andrzej/sub-manager-backend) with [ a React frontend ](https://projects.ajstepien.xyz/andrzej/sub-manager-frontend), but this version is much better! -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## What it does -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +Subman was inspired by my experiences submitting short fiction to magazines for publication. It allows the user to track where submissions are pending, in addition to meta-data such as genres, word count and so on. What you see here is the Minimum Shippable Product. -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. -## Learn More -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/next.config.mjs b/next.config.mjs index 4678774..5850805 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,10 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + output: "standalone", + webpack: (config) => { + config.externals = [...config.externals, "bcrypt"]; + return config; + }, +}; export default nextConfig; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3cbe396..043a9e3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,6 +10,13 @@ datasource db { url = "file:./dev.db" } +model User { +id Int @id @default(autoincrement()) +email String +password String + +} + model Story { id Int @id @default(autoincrement()) word_count Int diff --git a/prisma/script.js b/prisma/script.js index 65dac19..f8a8d21 100644 --- a/prisma/script.js +++ b/prisma/script.js @@ -1,27 +1,24 @@ -import { PrismaClient } from '@prisma/client' +import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient() +const prisma = new PrismaClient(); async function main() { - // ... you will write your Prisma Client queries here - const story = await prisma.story.update({ - where: { id: 1 }, - data: { - title: "Ghost Aliens of Mars", - genres: { set: [{ id: 1 }, { id: 2 }], create: { name: "alien-punk" } } - } - - }) - console.log(story) - + // ... you will write your Prisma Client queries here + const story = await prisma.story.update({ + where: { id: 1 }, + data: { + title: "Ghost Aliens of Mars", + genres: { set: [{ id: 1 }, { id: 2 }], create: { name: "alien-punk" } }, + }, + }); } main() - .then(async () => { - await prisma.$disconnect() - }) - .catch(async (e) => { - console.error(e) - await prisma.$disconnect() - process.exit(1) - }) + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/src/app/api/auth/actions.ts b/src/app/api/auth/actions.ts new file mode 100644 index 0000000..96dd094 --- /dev/null +++ b/src/app/api/auth/actions.ts @@ -0,0 +1,141 @@ +"use server" +import prisma from 'app/lib/db'; +import { jwtVerify, JWTPayload, decodeJwt, SignJWT } from 'jose'; +import { cookies } from 'next/headers'; +import { loginSchema, LoginSchema } from 'app/login/schema'; +import { NextResponse } from 'next/server'; + +export async function getJWTSecretKey() { + const secret = process.env.JWT_SECRET + if (!secret) throw new Error("There is no JWT secret key") + try { + const enc = new TextEncoder().encode(secret) + return enc + } catch (error) { + throw new Error("failed to getJWTSecretKey", error.message) + } +} + +export async function verifyJwt(token: string): Promise { + try { + const key = await getJWTSecretKey() + const { payload } = await jwtVerify(token, key) + return payload + } catch { + return null + } +} + +export async function getJwt() { + const cookieStore = cookies() + const token = cookieStore.get("token") + + if (token) { + try { + const payload = await verifyJwt(token.value) + if (payload) { + const authPayload = { + email: payload.email as string, + iat: payload.iat as number, + exp: payload.exp as number + } + return authPayload + } + } catch (error) { + + return null + } + } + return null +} + +export async function logout() { + const cookieStore = cookies() + const token = cookieStore.get('token') + + if (token) { + //empty catch swallows errors + try { + cookieStore.delete('token') + } catch { } + } + + const userData = cookieStore.get("userData") + if (userData) { + try { + cookieStore.delete('userData') + return true + } catch (_) { } + } + //return false if there is no userdata + return null +} + +export async function setUserDataCookie(userData) { + const cookieStore = cookies(); + cookieStore.set({ + name: 'userData', + value: JSON.stringify(userData), + path: '/', + maxAge: 3600, + sameSite: 'strict' + }) +} + + +export async function login(userLogin: LoginSchema) { + const isSafe = loginSchema.safeParse(userLogin) + try { + + if (!isSafe.success) throw new Error("parse failed") + const user = await prisma.user.findFirst({ where: { email: userLogin.email } }) + if (!user) throw new Error("user does not exist") + const bcrypt = require("bcrypt"); + const passwordIsValid = await bcrypt.compare(userLogin.password, user.password) + if (!passwordIsValid) throw new Error("password is not valid") + return { email: userLogin.email } + } catch (error) { + console.error("WHOOPS", error) + throw new Error('login failed') + } +} + +export async function jwtExpires() { + +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..6f439eb --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,65 @@ +import { NextResponse, NextRequest } from "next/server"; +import prisma from "app/lib/db"; +import { SignJWT } from "jose"; + +import { getJWTSecretKey, login, setUserDataCookie } from "../actions"; + +export interface UserLoginRequest { + email: string + password: string +} + +//render route afresh every time +const dynamic = 'force-dynamic' + +//POST endpoint +export async function POST(request: NextRequest) { + const body = await request.json() + const { email, password } = body + + if (!email || !password) { + const res = { + succes: false, + message: 'Email or password missing' + } + return NextResponse.json(res, { status: 400 }) + } + + try { + + //fetch user from db, throw if email or password are invalid + const user = await login({ email, password }) + + //create and sign JWT + const token = await new SignJWT({ + ...user + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(await getJWTSecretKey()) + + //make response + const res = { success: true } + const response = NextResponse.json(res) + + //Store jwt as secure http-only cookie + response.cookies.set({ + name: 'token', + value: token, + path: '/', //defines where the cookie can be accessed - in this case, site wide + maxAge: 3600, //1 hour + httpOnly: true, + sameSite: 'strict' + }) + //Store public user data as cookie + setUserDataCookie(user) + + return response + + } catch (error) { + console.error(error) + const res = { success: false, message: error.message || 'something went wrong' } + return NextResponse.json(res, { status: 500 }) + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..39ee4e9 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,20 @@ +import { revalidatePath } from "next/cache"; +import { NextRequest } from "next/server"; +import { logout } from "../actions"; + +const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + await logout() + revalidatePath('/login') + const response = { + success: true, + message: 'Logged out successfully', + }; + + return new Response(JSON.stringify(response), { + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/src/app/lib/filterFns.ts b/src/app/lib/filterFns.ts new file mode 100644 index 0000000..c9c5e2a --- /dev/null +++ b/src/app/lib/filterFns.ts @@ -0,0 +1,14 @@ +import { Genre } from "@prisma/client"; +import { FilterFn, Row } from "@tanstack/react-table"; + +export const genrePickerFilterFn = (row: Row, columnId: string, filterValue: any) => { + + const genres: Genre[] = row.getValue(columnId) + + for (let index = 0; index < genres.length; index++) { + if (genres[genres.length - 1].name.includes(filterValue)) { + return true + } + } + return false +} diff --git a/src/app/lib/validate.ts b/src/app/lib/validate.ts new file mode 100644 index 0000000..318eacd --- /dev/null +++ b/src/app/lib/validate.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { storySchema } from "app/ui/forms/schemas"; +import { Pub, Story } from "@prisma/client"; +import { pubSchema } from "app/ui/forms/schemas"; +import { StoryWithGenres } from "app/story/page"; + +//schemas + +const storySchemaTrimmed = storySchema.omit({ genres: true }) +const pubSchemaTrimmed = pubSchema.omit({ genres: true }) +const genreSchema = z.object({ id: z.number() }) +const genresSchema = z.array(genreSchema) + +export async function prepStoryData(data: Story): Promise<{ title: string, word_count: number }> { + const storyData = structuredClone(data) + //throw an error if validation fails + storySchemaTrimmed.safeParse(storyData) + return storyData +} + +export async function prepPubData(data: Pub): Promise { + const pubData = structuredClone(data) + pubSchemaTrimmed.safeParse(pubData) + return pubData +} + +export async function prepGenreData(data: number[]): Promise<{ id: number }[]> { + "use server" + + //prepare data + const genresArray = data.map((e) => { return { id: e } }) + + //prepare schemas + + //throws error if validation fails + genresSchema.safeParse(genresArray) + return genresArray + +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..42655b7 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,86 @@ +"use client" +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "@/components/ui/use-toast"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { redirect } from "next/navigation"; +import { loginSchema } from "./schema"; +import { useRouter } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import revalidate from "./revalidate"; +import { useState } from "react"; +import Link from "next/link"; + +export default function LoginForm() { + const router = useRouter() + const searchParams = useSearchParams() + const redirect = searchParams.get("from") ?? "/submission" + const form = useForm>({ + resolver: zodResolver(loginSchema), + }) + const [submitted, setSubmitted] = useState(false) + + const onSubmit = form.handleSubmit(async (data, event) => { + event.preventDefault() + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-type': 'application/json', + }, + body: JSON.stringify(data), + }) + if (res.status === 200) { + toast({ title: "login successful!" }) + setSubmitted(true) + await revalidate(redirect) + //BUG:the first time user logs in, page refreshes instead of redirecting + router.push(redirect) + } else { + toast({ title: "login failed!" }) + } + }) + + + return ( + <> + {submitted ?

Logging in...

: +
+ + ( + + Email Address + + + + + + )} + > + ( + + Password + + + + + + )} + > + +
+ + } + + + ) +} + diff --git a/src/app/login/revalidate.ts b/src/app/login/revalidate.ts new file mode 100644 index 0000000..0a9e8ce --- /dev/null +++ b/src/app/login/revalidate.ts @@ -0,0 +1,11 @@ +"use server" +import { revalidatePath } from "next/cache" +export default async function revalidate(path: string) { + try { + revalidatePath(path) + return true + } catch (error) { + console.error(error) + return false + } +} diff --git a/src/app/login/schema.ts b/src/app/login/schema.ts new file mode 100644 index 0000000..feceddf --- /dev/null +++ b/src/app/login/schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod" +export const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(6) +}) + +export type LoginSchema = z.infer diff --git a/src/app/publication/edit.tsx b/src/app/publication/edit.tsx new file mode 100644 index 0000000..2ca4a6a --- /dev/null +++ b/src/app/publication/edit.tsx @@ -0,0 +1,29 @@ +"use client" +import { Dialog, DialogHeader, DialogTrigger, DialogContent, DialogClose, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ComponentProps } from "react"; +import { Genre, Pub } from "@prisma/client"; +import { createPub } from "app/lib/create"; +import PubForm from "app/ui/forms/pub"; +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { PubWithGenres } from "./page"; + +export default function EditPubDialog({ genres, closeDialog, defaults, dbAction }: ComponentProps<"div"> & { genres: Genre[], closeDialog: () => void, defaults: PubWithGenres, dbAction: (data: Pub & { genres: number[] }) => Promise<{ success: string }> }) { + + + return ( + + <> + + Edit publication + Modify an entry for an existing publication. + + + + + + + + ) +} diff --git a/src/app/story/edit.tsx b/src/app/story/edit.tsx new file mode 100644 index 0000000..78551eb --- /dev/null +++ b/src/app/story/edit.tsx @@ -0,0 +1,27 @@ +"use client" +import { DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ComponentProps, useState } from "react"; +import { Genre, Story } from "@prisma/client"; +import StoryForm from "app/ui/forms/story"; +import { StoryWithGenres } from "./page"; + + +export default function EditStoryDialog({ genres, closeDialog, defaults, dbAction }: ComponentProps<"div"> & { genres: Genre[], closeDialog: () => void, defaults: StoryWithGenres, dbAction: (data: Story & { genres: number[] }) => Promise<{ success: string }> }) { + + + return ( + <> + + Edit story + Create an entry for a new story i.e. a thing you intend to submit for publication. + + + + + + + + ) +} + diff --git a/src/app/ui/forms/editSub.tsx b/src/app/ui/forms/editSub.tsx new file mode 100644 index 0000000..945cc4d --- /dev/null +++ b/src/app/ui/forms/editSub.tsx @@ -0,0 +1,265 @@ +"use client" + +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { Button } from "@/components/ui/button" +import { toast } from "@/components/ui/use-toast" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +import { Calendar } from "@/components/ui/calendar" +import { CalendarIcon } from "@radix-ui/react-icons" +import { cn } from "@/lib/utils" +import { format } from "date-fns" +import { + Form, + FormItem, + FormLabel, + FormField, + FormControl, + FormDescription, + FormMessage +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useState } from "react" +import { createSub } from "app/lib/create" +import { subSchema } from "./schemas" +import { updateSub } from "app/lib/update" +import { useRouter } from "next/navigation" + +export type SubForm = z.infer + + +export default function EditSubmissionForm({ stories, pubs, responses, defaults, closeDialog }) { + const form = useForm>({ + resolver: zodResolver(subSchema), + defaultValues: { + responseId: responses[0].id, + ...defaults + } + }) + const [isSubCalendarOpen, setIsSubCalendarOpen] = useState(false); + const [isRespCalendarOpen, setIsRespCalendarOpen] = useState(false); + const storiesSelectItems = stories.map(e => ( + + {e.title} + + )) + const pubsSelectItems = pubs.map(e => ( + + {e.title} + + )) + + const reponsesSelectItems = responses.map(e => ( + + {e.response} + + )) + + + const router = useRouter() + + async function onSubmit(values: z.infer) { + try { + const res = await updateSub(values) + if (res === undefined) throw new Error("something went wrong") + toast({ title: "Successfully created new submission!" }) + router.refresh() + closeDialog() + } catch (error) { + toast({ + title: "UH-OH", + description: error.message + }) + } + } + + function onErrors(errors) { + toast({ + title: "You have errors", + description: ( +
+					{JSON.stringify(errors, null, 2)}
+				
+ ), + }) + console.log(JSON.stringify(errors)) + } + + return ( +
+ + ( + + Story + + The piece you submitted + + + )} + /> + ( + + Publication + + The market you sent it to + + + )} + /> + + ( + + Date of submission + + + + + + + + {/* @ts-ignore */} + { field.onChange(e); setIsSubCalendarOpen(false); }} + disabled={(date) => + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + The date you sent it + + + + )} + /> + + ( + + Date of response + + + + + + + + {/* @ts-ignore */} + { field.onChange(e); setIsRespCalendarOpen(false); }} + disabled={(date) => + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + The date they wrote back + + + + )} + /> + + + ( + + Response + + The market you sent it to + + + )} + /> + + + + ) +} diff --git a/src/app/ui/forms/schemas.ts b/src/app/ui/forms/schemas.ts new file mode 100644 index 0000000..cfc2f2f --- /dev/null +++ b/src/app/ui/forms/schemas.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +export const storySchema = z.object({ + title: z.string().min(2).max(50), + word_count: z.coerce.number(), + genres: z.object({ id: z.number(), name: z.string() }).array() +}) + +export const pubSchema = z.object({ + id: z.coerce.number().optional(), + title: z.string().min(2).max(50), + link: z.string(), + query_after_days: z.coerce.number().min(30), + genres: z.array(z.number()), +}) + +export const subSchema = z.object({ + id: z.number().optional(), + storyId: z.coerce.number(), + pubId: z.coerce.number(), + submitted: z.coerce.date().transform((date) => date.toString()), + responded: z.coerce.date().transform((date) => { + if (date.toString() !== new Date(null).toString()) { + return date.toString() + } + return null + }).optional(), + responseId: z.coerce.number() +}) + .refine(object => { + const submitted = new Date(object.submitted) + const responded = object.responded ? new Date(object.responded) : null + return responded >= submitted || responded === null + }, + { + path: ["responded"], + message: "'Responded' must be a later date than 'submitted'" + }) + .refine(object => { + if (object.responded) { + //there is a 'responded' date and the response is not 'pending' + return object.responseId !== 7 + } + if (!object.responded) { + //there is not a 'responded' date and the response is pending + return object.responseId === 7 + } + }, + { + path: ["responseId"], + message: "A pending response cannot have a date, and a non-pending response must have a date" + } + ) + diff --git a/src/app/ui/logoutButton.tsx b/src/app/ui/logoutButton.tsx new file mode 100644 index 0000000..6ae2083 --- /dev/null +++ b/src/app/ui/logoutButton.tsx @@ -0,0 +1,26 @@ +"use client" +import { Button } from "@/components/ui/button" +import { LogOutIcon } from "lucide-react" +import { useRouter } from "next/navigation" + + +export default function LogoutButton() { + const router = useRouter() + + async function handleLogout() { + const res = await fetch('/api/auth/logout', { + method: 'POST', + headers: { + 'Content-type': 'application/json', + } + }) + console.log(res) + router.refresh() + } + + return ( + + ) +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..5fd702c --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,78 @@ +"use server" +import { NextRequest, NextResponse } from "next/server"; +import { verifyJwt } from "app/api/auth/actions"; + +const protectedRoutes = ['/story', '/submission', '/publication'] + +// Function to match the * wildcard character +function matchesWildcard(path: string, pattern: string): boolean { + if (pattern.endsWith('/*')) { + const basePattern = pattern.slice(0, -2); + return path.startsWith(basePattern); + } + return path === pattern; +} + +export default async function(request: NextRequest): Promise | undefined { + + + const url = request.nextUrl.clone() + url.pathname = "/login" + url.searchParams.set('from', request.nextUrl.pathname) + if (protectedRoutes.some(pattern => matchesWildcard(request.nextUrl.pathname, pattern))) { + const token = request.cookies.get('token') + //NOTE - may need to add logic to return 401 for api routes + + if (!token) { + console.log("there is no jwt") + return NextResponse.redirect(url) + } + + try { + //decode and verify jwt cookie + const jwtIsVerified = await verifyJwt(token.value) + if (!jwtIsVerified) { + //delete token + console.log('could not verify jwt') + request.cookies.delete('token') + return NextResponse.redirect(url) + } + } catch (error) { + //delete token (failsafe) + console.error("failed to very jwt", error.message) + request.cookies.delete('token') + return NextResponse.redirect(url) + } + + //TODO - TEST THIS BECAUSE IT PROBABLY DOESN'T WORK + //redirect from login if already logged in + let redirectToApp = false + if (request.nextUrl.pathname === "/login") { + const token = request.cookies.get("token") + if (token) { + try { + const payload = await verifyJwt(token.value) + if (payload) { + redirectToApp = true + } else { + request.cookies.delete('token') + } + } catch (error) { + request.cookies.delete('token') + } + } + } + + if (redirectToApp) { + return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL}/submission`) + } else { + return NextResponse.next() + } + + + + + } +} + +