From 89f6f5a560d82a775457f8b353db127a70c420e4 Mon Sep 17 00:00:00 2001 From: Aun Ali Date: Sat, 9 Aug 2025 15:50:15 +0000 Subject: [PATCH] add some kind of simple but secure auth --- database.db | Bin 0 -> 12288 bytes package-lock.json | 25 ++++++++++++++ package.json | 3 ++ src/app/login/page.tsx | 69 ++++++++++++++++++++++++++++++++++++++ src/app/page.tsx | 9 ++++- src/components/header.tsx | 13 ++++++- src/lib/actions.ts | 17 ++++++++++ src/lib/auth-actions.ts | 45 +++++++++++++++++++++++++ src/lib/db.ts | 37 +++++++++++++++----- src/lib/session.ts | 25 ++++++++++++++ src/middleware.ts | 27 +++++++++++++++ 11 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 database.db create mode 100644 src/app/login/page.tsx create mode 100644 src/lib/auth-actions.ts create mode 100644 src/lib/session.ts create mode 100644 src/middleware.ts diff --git a/database.db b/database.db new file mode 100644 index 0000000000000000000000000000000000000000..32dacf402f950312b26cb8a2ef59fa07101d2b7f GIT binary patch literal 12288 zcmeI1-EJGl6~~uWYEwz`aZxlE27&|K)J8}`vg|}iL8Oep2tp-W9~2VVNbKS4kUP=r z&U$86^5!mb-=jdEqEFM8D0<)j%yLDUXi?<0n1v`Vcjx2G@BGg>v*hXVfpmgq+T_g9 zcJfKm=_G$5N|Iz9uQj~dql1(8+7Ed5pa0jfo(#<2K18cKAN@V~_#YKAXJ}nNJ(*D!QcyD|{ zM<@G-qmy&`^Z1-bXOn09Pto^q{B+X0Y3-y_f+pkdC-n3gUS|gf%bQ#mP89CH-7d5f zHr!lxactgP`cK0d!JJ4(D+4ZUdFY?byLZo$kN*1M&%bpa{C@pk-GAJ>#D#c70+B!@ z5D7#Azm~xDXWee{$LsU6z2S-Avc&hxsHU`E%#BDTiksc1j9m)indBFupec%!b+(5S z&AC>xM{{PBcg{Zwj?G%hoONO0HCd)kmt5L$S)KoYol}PDUr-fZk6K{!> znRXfnmo0b8t`qfe$QmC{sDE(^@$=j_pybQ#T+zVc&CRR!ZY_kJHh1YA|1UNVE7mlMVY zrp%UtI|`NrUj~Apo8RyO+OU-VydeUUafumsi}Rxu&cD0cP0p@)^4;)Ma|t&dLV%2p zv~64ocZ_5h|avLB2D zhn$%U;fL@F3?|*evb}=kv)^=+t+(6B*TWliogN?1aRp)T$xB2e#Ky1>W)@cROysQb zsk_qDY`J^%3i3Dh6+Mt?kHR*Z&`zozLULYI^=c*Qnefrqy@v5Gptdab~vT@p^Y zPH)HO;}t+J-dj(;e!KR|`VYe~B7|Yo;|dO_a@s|ZRvvVF!%v4zI~Pa~2$R=~cD!xp z?Ps+0s9y_a`dXy}I`dfDyv3BBOYG^r>)EBoql6PYD?--!iJ3VflUd3utPt;zqm_8( zK?c;wLt(MVVz?9Lo=TraZB+_`me7!OOeQB%O#EN{%+@J3?ZDUy+ZHIZ$^N z%~I4KrQu*K(APGvznNBxZ0m(hwvc5h&>L{fVnC8j%A1Oi#o+dIv5+2Nu<#2o{MgW7 zX(o(!t6%of_=t8;CNUZ_DRTHb%FiGxsJGS?N-gI0b6#bhP8pWf1{=b!G2GL{#n!HO zA$_?5>BmkNfp~a)I{XICZxZ)@kozKYX>*gpwl+~~&?sc=CMJBjX3bSL)s>^sJ}Ot> z7Xl$jq-nYo2&B!kx(9NLtP2LbGT;Ff;D3q;^Q*L#47qTj$7R&A%I|VG6!@**F8JK! z=K$x$mIEe1t219jxshe4zJ5do+x^48uTGkN=tAFCLLVBoGNi0+B!@5D7#Akw7F62}A;s UKqT=0L*RqX+Wk)Rvw`jY8 + {pending && } + Login + + ); +} + +export default function LoginPage() { + const [state, formAction] = useFormState(login, initialState); + const { toast } = useToast(); + + useEffect(() => { + if (state?.message) { + toast({ + title: 'Login Failed', + description: state.message, + variant: 'destructive', + }); + } + }, [state, toast]); + + return ( +
+ + +
+ +

PromptVerse

+
+ Welcome Back + Enter your password to access your prompts. +
+ +
+
+ + +
+ + +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c9185ca..221a906 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,19 @@ +import { getSession } from '@/lib/session'; +import { redirect } from 'next/navigation'; import { Header } from '@/components/header'; import { PromptList, PromptListSkeleton } from '@/components/prompt-list'; import { Suspense } from 'react'; -export default function Home({ +export default async function Home({ searchParams, }: { searchParams?: { q?: string }; }) { + const session = await getSession(); + if (!session) { + redirect('/login'); + } + const query = searchParams?.q || ''; return ( diff --git a/src/components/header.tsx b/src/components/header.tsx index d6bc2b3..4b28210 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,10 +1,12 @@ 'use client'; import { Logo } from '@/components/icons'; import { Input } from '@/components/ui/input'; -import { Search } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Search, LogOut } from 'lucide-react'; import { CreatePromptDialog } from '@/components/create-prompt-dialog'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useRef } from 'react'; +import { logout } from '@/lib/auth-actions'; export function Header() { const searchParams = useSearchParams(); @@ -27,6 +29,10 @@ export function Header() { }, 300); }; + const handleLogout = async () => { + await logout(); + } + return (
@@ -45,6 +51,11 @@ export function Header() { />
+
+ +
diff --git a/src/lib/actions.ts b/src/lib/actions.ts index b481be7..e466a34 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -7,8 +7,15 @@ import type { Prompt } from './types'; import { suggestTags as suggestTagsFlow } from '@/ai/flows/suggest-tags'; import { suggestTitle as suggestTitleFlow } from '@/ai/flows/suggest-title'; import { z } from 'zod'; +import { getSession } from './session'; +import { redirect } from 'next/navigation'; export async function getPrompts(query: string = ''): Promise { + const session = await getSession(); + if (!session) { + return []; + } + const db = await getDb(); const lowerCaseQuery = `%${query.toLowerCase()}%`; @@ -38,6 +45,11 @@ const CreatePromptSchema = z.object({ export async function createPrompt(prevState: any, formData: FormData) { + const session = await getSession(); + if (!session) { + redirect('/login'); + } + const validatedFields = CreatePromptSchema.safeParse({ content: formData.get('content'), notes: formData.get('notes'), @@ -76,6 +88,11 @@ export async function createPrompt(prevState: any, formData: FormData) { } export async function suggestPromptTags(promptText: string): Promise<{tags?: string[]; error?: string}> { + const session = await getSession(); + if (!session) { + return { error: 'You must be logged in to use AI suggestions.' }; + } + if (!promptText || promptText.trim().length < 20) { return { error: 'Please provide a more detailed prompt for better suggestions.' }; } diff --git a/src/lib/auth-actions.ts b/src/lib/auth-actions.ts new file mode 100644 index 0000000..e0a3e74 --- /dev/null +++ b/src/lib/auth-actions.ts @@ -0,0 +1,45 @@ +'use server'; +import { redirect } from 'next/navigation'; +import { compare } from 'bcryptjs'; +import { SignJWT } from 'jose'; +import { cookies } from 'next/headers'; + +const secretKey = process.env.JWT_SECRET_KEY; +const key = new TextEncoder().encode(secretKey); + +export async function encrypt(payload: any) { + return await new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1d') // 1 day + .sign(key); +} + +export async function login(prevState: any, formData: FormData) { + const password = formData.get('password') as string; + + if (!process.env.ADMIN_PASSWORD) { + return { message: 'Admin password is not set.' }; + } + + const passwordsMatch = await compare(password, process.env.ADMIN_PASSWORD); + + if (passwordsMatch) { + // Create the session + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); + const session = await encrypt({ user: { id: 'admin' }, expires }); + + // Save the session in a cookie + cookies().set('session', session, { expires, httpOnly: true }); + + redirect('/'); + } + + return { message: 'Invalid password.' }; +} + +export async function logout() { + // Destroy the session + cookies().set('session', '', { expires: new Date(0) }); + redirect('/login'); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 84e6e05..46b038c 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -2,10 +2,36 @@ import { open } from 'sqlite'; import sqlite3 from 'sqlite3'; import { initialPrompts } from './data'; +import { hash } from 'bcryptjs'; // Singleton pattern to ensure only one database connection is created. let db: Awaited> | null = null; +async function seedDatabase(db: Awaited>) { + // Seed initial data if the table is empty + const countResult = await db.get('SELECT COUNT(*) as count FROM prompts'); + if (countResult && countResult.count === 0) { + const stmt = await db.prepare('INSERT INTO prompts (title, content, notes, tags, createdAt) VALUES (?, ?, ?, ?, ?)'); + for (const prompt of initialPrompts) { + await stmt.run(prompt.title, prompt.content, prompt.notes, JSON.stringify(prompt.tags), prompt.createdAt); + } + await stmt.finalize(); + } +} + +async function setupAdminPassword() { + if (process.env.ADMIN_PASSWORD) { + const plainPassword = process.env.ADMIN_PASSWORD; + // In a real app, you wouldn't log this. This is for demonstration. + console.log(`Hashing admin password: ${plainPassword}`); + process.env.ADMIN_PASSWORD = await hash(plainPassword, 10); + console.log(`Hashed admin password stored in environment.`); + } else { + console.warn("ADMIN_PASSWORD environment variable not set. Using default."); + process.env.ADMIN_PASSWORD = await hash('admin', 10); + } +} + export async function getDb() { if (!db) { const newDb = await open({ @@ -25,15 +51,8 @@ export async function getDb() { ); `); - // Seed initial data if the table is empty - const countResult = await newDb.get('SELECT COUNT(*) as count FROM prompts'); - if (countResult && countResult.count === 0) { - const stmt = await newDb.prepare('INSERT INTO prompts (title, content, notes, tags, createdAt) VALUES (?, ?, ?, ?, ?)'); - for (const prompt of initialPrompts) { - await stmt.run(prompt.title, prompt.content, prompt.notes, JSON.stringify(prompt.tags), prompt.createdAt); - } - await stmt.finalize(); - } + await setupAdminPassword(); + await seedDatabase(newDb); db = newDb; } diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..b46c21d --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,25 @@ +'use server'; +import 'server-only'; +import { cookies } from 'next/headers'; +import { jwtVerify } from 'jose'; + +const secretKey = process.env.JWT_SECRET_KEY; +const key = new TextEncoder().encode(secretKey); + +export async function decrypt(input: string): Promise { + try { + const { payload } = await jwtVerify(input, key, { + algorithms: ['HS256'], + }); + return payload; + } catch (error) { + return null; + } +} + +export async function getSession() { + const sessionCookie = cookies().get('session')?.value; + if (!sessionCookie) return null; + const session = await decrypt(sessionCookie); + return session; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..a35b848 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from './lib/session'; + +const protectedRoutes = ['/']; +const publicRoutes = ['/login']; + +export async function middleware(req: NextRequest) { + const path = req.nextUrl.pathname; + const isProtectedRoute = protectedRoutes.includes(path); + const isPublicRoute = publicRoutes.includes(path); + + const session = await getSession(); + + if (isProtectedRoute && !session) { + return NextResponse.redirect(new URL('/login', req.nextUrl)); + } + + if (isPublicRoute && session && !req.nextUrl.searchParams.has('from')) { + return NextResponse.redirect(new URL('/', req.nextUrl)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'], +};