add some kind of simple but secure auth
This commit is contained in:
parent
d32bf5a1f3
commit
89f6f5a560
11 changed files with 259 additions and 11 deletions
BIN
database.db
Normal file
BIN
database.db
Normal file
Binary file not shown.
25
package-lock.json
generated
25
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
69
src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
45
src/lib/auth-actions.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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
25
src/lib/session.ts
Normal 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
27
src/middleware.ts
Normal 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$).*)'],
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue