diff --git a/database.db b/database.db new file mode 100644 index 0000000..32dacf4 Binary files /dev/null and b/database.db differ diff --git a/package-lock.json b/package-lock.json index 5464fbb..82dbbf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", + "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -39,6 +40,7 @@ "embla-carousel-react": "^8.6.0", "firebase": "^11.9.1", "genkit": "^1.14.1", + "jose": "^5.8.0", "lucide-react": "^0.475.0", "next": "15.3.3", "patch-package": "^8.0.0", @@ -54,6 +56,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -4296,6 +4299,13 @@ "https://trpc.io/sponsor" ] }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/caseless": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", @@ -4761,6 +4771,12 @@ } ] }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/bignumber.js": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", @@ -7714,6 +7730,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 7d3fc55..fdb530b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", + "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -43,6 +44,7 @@ "embla-carousel-react": "^8.6.0", "firebase": "^11.9.1", "genkit": "^1.14.1", + "jose": "^5.8.0", "lucide-react": "^0.475.0", "next": "15.3.3", "patch-package": "^8.0.0", @@ -58,6 +60,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..ad5ec0c --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,69 @@ +'use client'; +import { useFormState, useFormStatus } from 'react-dom'; +import { login } from '@/lib/auth-actions'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Loader2 } from 'lucide-react'; +import { useEffect } from 'react'; +import { useToast } from '@/hooks/use-toast'; +import { Logo } from '@/components/icons'; + +const initialState = { + message: '', +}; + +function SubmitButton() { + const { pending } = useFormStatus(); + return ( + + ); +} + +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$).*)'], +};