Merge remote-tracking branch 'refs/remotes/origin/main'
Gitea/subman-nextjs/pipeline/head There was a failure building this commit Details

This commit is contained in:
andrzej 2024-10-02 19:30:25 +02:00
commit 3b6652c617
20 changed files with 928 additions and 50 deletions

3
.gitignore vendored
View File

@ -34,3 +34,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
#secret
.env

21
Jenkinsfile vendored Normal file
View File

@ -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)])
}
}
}
}

View File

@ -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 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!
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
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.

View File

@ -1,4 +1,10 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = {}; const nextConfig = {
output: "standalone",
webpack: (config) => {
config.externals = [...config.externals, "bcrypt"];
return config;
},
};
export default nextConfig; export default nextConfig;

View File

@ -10,6 +10,13 @@ datasource db {
url = "file:./dev.db" url = "file:./dev.db"
} }
model User {
id Int @id @default(autoincrement())
email String
password String
}
model Story { model Story {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
word_count Int word_count Int

View File

@ -1,6 +1,6 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient() const prisma = new PrismaClient();
async function main() { async function main() {
// ... you will write your Prisma Client queries here // ... you will write your Prisma Client queries here
@ -8,20 +8,17 @@ async function main() {
where: { id: 1 }, where: { id: 1 },
data: { data: {
title: "Ghost Aliens of Mars", title: "Ghost Aliens of Mars",
genres: { set: [{ id: 1 }, { id: 2 }], create: { name: "alien-punk" } } genres: { set: [{ id: 1 }, { id: 2 }], create: { name: "alien-punk" } },
} },
});
})
console.log(story)
} }
main() main()
.then(async () => { .then(async () => {
await prisma.$disconnect() await prisma.$disconnect();
}) })
.catch(async (e) => { .catch(async (e) => {
console.error(e) console.error(e);
await prisma.$disconnect() await prisma.$disconnect();
process.exit(1) process.exit(1);
}) });

141
src/app/api/auth/actions.ts Normal file
View File

@ -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<Uint8Array>() {
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<JWTPayload | null> {
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() {
}

View File

@ -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 })
}
}

View File

@ -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',
},
});
}

14
src/app/lib/filterFns.ts Normal file
View File

@ -0,0 +1,14 @@
import { Genre } from "@prisma/client";
import { FilterFn, Row } from "@tanstack/react-table";
export const genrePickerFilterFn = (row: Row<any>, 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
}

39
src/app/lib/validate.ts Normal file
View File

@ -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<Pub> {
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
}

86
src/app/login/page.tsx Normal file
View File

@ -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<z.infer<typeof loginSchema>>({
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 ? <p>Logging in...</p> :
<Form {...form}>
<form onSubmit={onSubmit} className="mt-20 flex flex-col items-center space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input placeholder="email goes here" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
></FormField>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password goes here" type="password"{...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
></FormField>
<Button type="submit" className="mt-4">SUBMIT</Button>
</form>
</Form>
}
</>
)
}

View File

@ -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
}
}

7
src/app/login/schema.ts Normal file
View File

@ -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<typeof loginSchema>

View File

@ -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 (
<>
<DialogHeader>
<DialogTitle>Edit publication</DialogTitle>
<DialogDescription>Modify an entry for an existing publication.</DialogDescription>
</DialogHeader>
<PubForm dbAction={dbAction} genres={genres} closeDialog={closeDialog} defaults={defaults} />
<DialogFooter>
<Button form="pubform">Submit</Button>
</DialogFooter>
</>
)
}

27
src/app/story/edit.tsx Normal file
View File

@ -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 (
<>
<DialogHeader>
<DialogTitle>Edit story</DialogTitle>
<DialogDescription>Create an entry for a new story i.e. a thing you intend to submit for publication.</DialogDescription>
</DialogHeader>
<StoryForm dbAction={dbAction} genres={genres} className="" closeDialog={closeDialog} defaults={defaults} />
<DialogFooter>
<Button form="storyform">Submit</Button>
</DialogFooter>
</>
)
}

View File

@ -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<typeof subSchema>
export default function EditSubmissionForm({ stories, pubs, responses, defaults, closeDialog }) {
const form = useForm<z.infer<typeof subSchema>>({
resolver: zodResolver(subSchema),
defaultValues: {
responseId: responses[0].id,
...defaults
}
})
const [isSubCalendarOpen, setIsSubCalendarOpen] = useState(false);
const [isRespCalendarOpen, setIsRespCalendarOpen] = useState(false);
const storiesSelectItems = stories.map(e => (
<SelectItem value={e.id.toString()} key={e.title}>
{e.title}
</SelectItem>
))
const pubsSelectItems = pubs.map(e => (
<SelectItem value={e.id} key={e.title}>
{e.title}
</SelectItem>
))
const reponsesSelectItems = responses.map(e => (
<SelectItem value={e.id} key={e.title}>
{e.response}
</SelectItem>
))
const router = useRouter()
async function onSubmit(values: z.infer<typeof subSchema>) {
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: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(errors, null, 2)}</code>
</pre>
),
})
console.log(JSON.stringify(errors))
}
return (
<Form {...form}>
<form id="subform" onSubmit={form.handleSubmit(onSubmit, onErrors)} className="space-y-2 md:space-y-8">
<FormField
control={form.control}
name="storyId"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm md:text-base">Story</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select something">
<p>{stories?.find(e => e.id === Number(field.value))?.title ?? null}</p>
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
{storiesSelectItems}
</SelectContent>
</Select>
<FormDescription className="text-xs md:text-base">The piece you submitted</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pubId"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm md:text-base">Publication</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select something">
<p>{pubs?.find(e => e.id === Number(field.value))?.title ?? null}</p>
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
{pubsSelectItems}
</SelectContent>
</Select>
<FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="submitted"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-sm md:text-base">Date of submission</FormLabel>
<Popover modal={true} open={isSubCalendarOpen} onOpenChange={setIsSubCalendarOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
{/* @ts-ignore */}
<Calendar mode="single" selected={field.value}
onSelect={(e) => { field.onChange(e); setIsSubCalendarOpen(false); }}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormDescription className="text-xs md:text-base">
The date you sent it
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="responded"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-sm md:text-base">Date of response</FormLabel>
<Popover modal={true} open={isRespCalendarOpen} onOpenChange={setIsRespCalendarOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
{/* @ts-ignore */}
<Calendar selected={field.value} onSelect={(e) => { field.onChange(e); setIsRespCalendarOpen(false); }}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormDescription className="text-xs md:text-base">
The date they wrote back
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="responseId"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm md:text-base">Response</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
<FormControl>
<SelectTrigger>
<SelectValue>
<p>{responses?.find(e => e.id === Number(field.value))?.response ?? null}</p>
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
{reponsesSelectItems}
</SelectContent>
</Select>
<FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)
}

View File

@ -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"
}
)

View File

@ -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 (
<Button variant="outline" className="w-fit" onClick={handleLogout} >
<LogOutIcon />
</Button>
)
}

78
src/middleware.ts Normal file
View File

@ -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<NextResponse> | 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()
}
}
}