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 (
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$).*)'],
+};