add some kind of simple but secure auth

This commit is contained in:
Aun Ali 2025-08-09 15:50:15 +00:00
parent d32bf5a1f3
commit 89f6f5a560
11 changed files with 259 additions and 11 deletions

BIN
database.db Normal file

Binary file not shown.

25
package-lock.json generated
View file

@ -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",

View file

@ -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",

69
src/app/login/page.tsx Normal file
View file

@ -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 (
<Button type="submit" className="w-full" disabled={pending}>
{pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Login
</Button>
);
}
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 (
<main className="flex items-center justify-center min-h-screen bg-background">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<div className="flex justify-center items-center mb-4">
<Logo className="h-8 w-8 mr-2 text-primary" />
<h1 className="font-bold text-2xl">PromptVerse</h1>
</div>
<CardTitle>Welcome Back</CardTitle>
<CardDescription>Enter your password to access your prompts.</CardDescription>
</CardHeader>
<CardContent>
<form action={formAction} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
required
/>
</div>
<SubmitButton />
</form>
</CardContent>
</Card>
</main>
);
}

View file

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

View file

@ -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 (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 max-w-7xl items-center justify-between mx-auto px-4 sm:px-6 lg:px-8">
@ -45,6 +51,11 @@ export function Header() {
/>
</div>
<CreatePromptDialog />
<form action={handleLogout}>
<Button variant="ghost" size="icon" type="submit" aria-label="Logout">
<LogOut className="h-4 w-4" />
</Button>
</form>
</div>
</div>
</header>

View file

@ -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<Prompt[]> {
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.' };
}

45
src/lib/auth-actions.ts Normal file
View file

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

View file

@ -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<ReturnType<typeof open>> | null = null;
async function seedDatabase(db: Awaited<ReturnType<typeof open>>) {
// 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;
}

25
src/lib/session.ts Normal file
View file

@ -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<any> {
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;
}

27
src/middleware.ts Normal file
View file

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