Merge remote-tracking branch 'refs/remotes/origin/main'
	
		
			
	
		
	
	
		
			
				
	
				Gitea/subman-nextjs/pipeline/head There was a failure building this commit
				
					Details
				
			
		
	
				
					
				
			
				
	
				Gitea/subman-nextjs/pipeline/head There was a failure building this commit
				
					Details
				
			
		
	This commit is contained in:
		
						commit
						3b6652c617
					
				| 
						 | 
				
			
			@ -34,3 +34,6 @@ yarn-error.log*
 | 
			
		|||
# typescript
 | 
			
		||||
*.tsbuildinfo
 | 
			
		||||
next-env.d.ts
 | 
			
		||||
 | 
			
		||||
#secret
 | 
			
		||||
.env
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)])
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										38
									
								
								README.md
								
								
								
								
							
							
						
						
									
										38
									
								
								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.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
  });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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() {
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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 })
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
			}
 | 
			
		||||
		</>
 | 
			
		||||
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
    </>
 | 
			
		||||
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
    </>
 | 
			
		||||
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
		}
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue