Add user authentication with login/register

- Add users and sessions tables to database schema
- Add bcryptjs for password hashing
- Create auth API routes (login, register, logout, me)
- Add AuthProvider context for client-side auth state
- Update all API routes to require authentication and filter by userId
- Create login and register pages
- Add AppShell component for authenticated layout
- Update all pages to use AppShell and show user info
- Each user now has their own private entries and tags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-24 06:18:41 +00:00
parent 5555c1e6b5
commit 1455b0acd1
27 changed files with 1039 additions and 75 deletions

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { loginUser, setSessionCookie } from "@/lib/auth";
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password are required" },
{ status: 400 }
);
}
const result = await loginUser(email, password);
if (result.error) {
return NextResponse.json({ error: result.error }, { status: 401 });
}
await setSessionCookie(result.sessionId!);
return NextResponse.json({ user: result.user });
} catch (error) {
console.error("Login error:", error);
return NextResponse.json({ error: "Login failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { clearSessionCookie } from "@/lib/auth";
export async function POST() {
try {
await clearSessionCookie();
return NextResponse.json({ success: true });
} catch (error) {
console.error("Logout error:", error);
return NextResponse.json({ error: "Logout failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
export async function GET() {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ user: null });
}
return NextResponse.json({ user });
} catch (error) {
console.error("Session check error:", error);
return NextResponse.json({ user: null });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { registerUser, createSession, setSessionCookie } from "@/lib/auth";
export async function POST(request: NextRequest) {
try {
const { email, password, name } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password are required" },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: "Password must be at least 6 characters" },
{ status: 400 }
);
}
const result = await registerUser(email, password, name);
if (result.error) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
// Create session and set cookie
const sessionId = await createSession(result.user!.id);
await setSessionCookie(sessionId);
return NextResponse.json({ user: result.user });
} catch (error) {
console.error("Registration error:", error);
return NextResponse.json(
{ error: "Registration failed" },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { getSession } from "@/lib/auth";
import type { UpdateEntryRequest, EntryWithTags } from "@/lib/types";
import { v4 as uuidv4 } from "uuid";
@@ -10,13 +11,23 @@ interface RouteParams {
// GET /api/entries/[id] - Get single entry
export async function GET(_request: NextRequest, { params }: RouteParams) {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
try {
const entry = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.id, id))
.where(
and(
eq(schema.entries.id, id),
eq(schema.entries.userId, user.id)
)
)
.limit(1);
if (entry.length === 0) {
@@ -43,6 +54,11 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
// PATCH /api/entries/[id] - Update entry
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
try {
@@ -52,7 +68,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const existing = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.id, id))
.where(
and(
eq(schema.entries.id, id),
eq(schema.entries.userId, user.id)
)
)
.limit(1);
if (existing.length === 0) {
@@ -82,7 +103,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
let tag = await db
.select()
.from(schema.tags)
.where(eq(schema.tags.name, normalizedName))
.where(
and(
eq(schema.tags.userId, user.id),
eq(schema.tags.name, normalizedName)
)
)
.limit(1);
let tagId: string;
@@ -90,6 +116,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
tagId = uuidv4();
await db.insert(schema.tags).values({
id: tagId,
userId: user.id,
name: normalizedName,
createdAt: now,
});
@@ -131,13 +158,23 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
// DELETE /api/entries/[id] - Delete entry
export async function DELETE(_request: NextRequest, { params }: RouteParams) {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
try {
const existing = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.id, id))
.where(
and(
eq(schema.entries.id, id),
eq(schema.entries.userId, user.id)
)
)
.limit(1);
if (existing.length === 0) {

View File

@@ -2,10 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { eq, desc, and, inArray, gte } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { getSession } from "@/lib/auth";
import type { CreateEntryRequest, EntryWithTags } from "@/lib/types";
// GET /api/entries - List entries with optional filters
export async function GET(request: NextRequest) {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const moods = searchParams.get("moods")?.split(",").map(Number).filter(Boolean);
const tagId = searchParams.get("tagId");
@@ -13,8 +19,8 @@ export async function GET(request: NextRequest) {
const since = searchParams.get("since"); // YYYY-MM-DD
try {
// Build conditions
const conditions = [];
// Build conditions - always filter by userId
const conditions = [eq(schema.entries.userId, user.id)];
if (moods && moods.length > 0) {
conditions.push(inArray(schema.entries.mood, moods));
}
@@ -31,7 +37,7 @@ export async function GET(request: NextRequest) {
const entries = await db
.select()
.from(schema.entries)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.where(and(...conditions))
.orderBy(desc(schema.entries.date), desc(schema.entries.createdAt));
// Get tags for each entry
@@ -62,6 +68,11 @@ export async function GET(request: NextRequest) {
// POST /api/entries - Create or update today's entry
export async function POST(request: NextRequest) {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body: CreateEntryRequest = await request.json();
const { date, text, mood, roughDay, tagNames } = body;
@@ -72,11 +83,16 @@ export async function POST(request: NextRequest) {
const now = Date.now();
// Check if entry exists for this date
// Check if entry exists for this date for this user
const existing = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.date, date))
.where(
and(
eq(schema.entries.userId, user.id),
eq(schema.entries.date, date)
)
)
.limit(1);
let entryId: string;
@@ -98,6 +114,7 @@ export async function POST(request: NextRequest) {
entryId = uuidv4();
await db.insert(schema.entries).values({
id: entryId,
userId: user.id,
date,
text,
mood: mood ?? null,
@@ -117,11 +134,16 @@ export async function POST(request: NextRequest) {
const normalizedName = name.toLowerCase().trim();
if (!normalizedName) continue;
// Find or create tag
// Find or create tag for this user
let tag = await db
.select()
.from(schema.tags)
.where(eq(schema.tags.name, normalizedName))
.where(
and(
eq(schema.tags.userId, user.id),
eq(schema.tags.name, normalizedName)
)
)
.limit(1);
let tagId: string;
@@ -129,6 +151,7 @@ export async function POST(request: NextRequest) {
tagId = uuidv4();
await db.insert(schema.tags).values({
id: tagId,
userId: user.id,
name: normalizedName,
createdAt: now,
});

View File

@@ -1,20 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { eq, desc } from "drizzle-orm";
import { eq, desc, and } from "drizzle-orm";
import { getSession } from "@/lib/auth";
import type { EntryWithTags, ExportFormat } from "@/lib/types";
import { MOOD_LABELS, APP_NAME } from "@/lib/constants";
import { formatDateLong } from "@/lib/utils/date";
// POST /api/export - Export all entries
// POST /api/export - Export all entries for current user
export async function POST(request: NextRequest) {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json();
const format: ExportFormat = body.format || "markdown";
// Fetch all entries with tags
// Fetch all entries with tags for this user
const entries = await db
.select()
.from(schema.entries)
.where(eq(schema.entries.userId, user.id))
.orderBy(desc(schema.entries.date));
const entriesWithTags: EntryWithTags[] = [];
@@ -36,6 +43,7 @@ export async function POST(request: NextRequest) {
{
exportedAt: new Date().toISOString(),
appName: APP_NAME,
user: { email: user.email, name: user.name },
entries: entriesWithTags,
},
null,
@@ -52,7 +60,8 @@ export async function POST(request: NextRequest) {
// Markdown export
let markdown = `# ${APP_NAME} Export\n\n`;
markdown += `Exported on ${new Date().toLocaleDateString("en-US", { dateStyle: "long" })}\n\n`;
markdown += `Exported on ${new Date().toLocaleDateString("en-US", { dateStyle: "long" })}\n`;
markdown += `Account: ${user.email}\n\n`;
markdown += `---\n\n`;
// Group by date

View File

@@ -1,26 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { desc, like, sql } from "drizzle-orm";
import { desc, like, sql, eq, and } from "drizzle-orm";
import { getSession } from "@/lib/auth";
// GET /api/tags - Get tags (recent or search)
export async function GET(request: NextRequest) {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const search = searchParams.get("search");
const limit = parseInt(searchParams.get("limit") || "10", 10);
try {
if (search) {
// Search tags by name
// Search tags by name for this user
const tags = await db
.select()
.from(schema.tags)
.where(like(schema.tags.name, `%${search.toLowerCase()}%`))
.where(
and(
eq(schema.tags.userId, user.id),
like(schema.tags.name, `%${search.toLowerCase()}%`)
)
)
.limit(limit);
return NextResponse.json(tags);
}
// Get most recently used tags (by entry count)
// Get most recently used tags (by entry count) for this user
const tagsWithCount = await db
.select({
id: schema.tags.id,
@@ -30,6 +41,7 @@ export async function GET(request: NextRequest) {
})
.from(schema.tags)
.leftJoin(schema.entryTags, sql`${schema.tags.id} = ${schema.entryTags.tagId}`)
.where(eq(schema.tags.userId, user.id))
.groupBy(schema.tags.id)
.orderBy(desc(sql`use_count`), desc(schema.tags.createdAt))
.limit(limit);

View File

@@ -2,6 +2,7 @@
import { use, useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import { EntryForm } from "@/components/EntryForm";
import { ToastContainer } from "@/components/Toast";
import { useToast } from "@/hooks/useToast";
@@ -78,7 +79,7 @@ export default function EntryPage({ params }: EntryPageProps) {
}, [entry, id, router, showToast]);
return (
<div>
<AppShell>
<header className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Link
@@ -133,6 +134,6 @@ export default function EntryPage({ params }: EntryPageProps) {
)}
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
</div>
</AppShell>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { AppShell } from "@/components/AppShell";
import { Download, FileJson, FileText, Loader2 } from "lucide-react";
import { APP_NAME } from "@/lib/constants";
@@ -42,7 +43,7 @@ export default function ExportPage() {
};
return (
<div>
<AppShell>
<header className="mb-8">
<h1 className="text-2xl font-light">{APP_NAME}</h1>
<p className="text-sm text-muted">Export</p>
@@ -97,6 +98,6 @@ export default function ExportPage() {
</button>
</div>
</div>
</div>
</AppShell>
);
}

View File

@@ -1,7 +1,7 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Navigation } from "@/components/Navigation";
import { APP_NAME } from "@/lib/constants";
import { AuthProvider } from "@/components/AuthProvider";
export const metadata: Metadata = {
title: APP_NAME,
@@ -31,9 +31,8 @@ export default function RootLayout({
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className="antialiased min-h-screen pb-20">
<main className="max-w-lg mx-auto px-4 py-6">{children}</main>
<Navigation />
<body className="antialiased min-h-screen">
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);

108
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { APP_NAME } from "@/lib/constants";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Login failed");
return;
}
router.push("/");
router.refresh();
} catch {
setError("An error occurred");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-light mb-2">{APP_NAME}</h1>
<p className="text-muted">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm text-muted mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-surface border border-border rounded-lg focus:outline-none focus:border-muted"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm text-muted mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 bg-surface border border-border rounded-lg focus:outline-none focus:border-muted"
placeholder="Enter your password"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-accent text-white rounded-lg font-medium hover:bg-accent/90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="animate-spin" size={18} />}
Sign in
</button>
</form>
<p className="text-center text-sm text-muted mt-6">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-accent hover:underline">
Create one
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,17 +1,34 @@
"use client";
import { EntryForm } from "@/components/EntryForm";
import { AppShell } from "@/components/AppShell";
import { APP_NAME } from "@/lib/constants";
import { formatDate, getLocalDate } from "@/lib/utils/date";
import { useAuth } from "@/components/AuthProvider";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { ChevronRight, LogOut } from "lucide-react";
export default function HomePage() {
const today = getLocalDate();
const { user, logout } = useAuth();
return (
<div>
<header className="mb-8">
<h1 className="text-2xl font-light mb-1">{APP_NAME}</h1>
<p className="text-sm text-muted">{formatDate(today)}</p>
<AppShell>
<header className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-2xl font-light mb-1">{APP_NAME}</h1>
<p className="text-sm text-muted">{formatDate(today)}</p>
</div>
{user && (
<button
onClick={logout}
className="p-2 text-muted hover:text-foreground"
aria-label="Sign out"
title={`Signed in as ${user.email}`}
>
<LogOut size={20} />
</button>
)}
</header>
<EntryForm />
@@ -25,6 +42,6 @@ export default function HomePage() {
<ChevronRight size={20} />
</Link>
</div>
</div>
</AppShell>
);
}

124
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { APP_NAME } from "@/lib/constants";
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name: name || undefined }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Registration failed");
return;
}
router.push("/");
router.refresh();
} catch {
setError("An error occurred");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-light mb-2">{APP_NAME}</h1>
<p className="text-muted">Create your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm text-muted mb-1">
Name (optional)
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 bg-surface border border-border rounded-lg focus:outline-none focus:border-muted"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm text-muted mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-surface border border-border rounded-lg focus:outline-none focus:border-muted"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm text-muted mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-4 py-3 bg-surface border border-border rounded-lg focus:outline-none focus:border-muted"
placeholder="At least 6 characters"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-accent text-white rounded-lg font-medium hover:bg-accent/90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="animate-spin" size={18} />}
Create account
</button>
</form>
<p className="text-center text-sm text-muted mt-6">
Already have an account?{" "}
<Link href="/login" className="text-accent hover:underline">
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,14 +1,37 @@
"use client";
import { AppShell } from "@/components/AppShell";
import { useAuth } from "@/components/AuthProvider";
import { APP_NAME } from "@/lib/constants";
import { LogOut } from "lucide-react";
export default function SettingsPage() {
const { user, logout } = useAuth();
return (
<div>
<AppShell>
<header className="mb-8">
<h1 className="text-2xl font-light">{APP_NAME}</h1>
<p className="text-sm text-muted">Settings</p>
</header>
<div className="space-y-6">
{/* Account info */}
{user && (
<div className="p-4 bg-surface border border-border rounded-xl">
<h3 className="font-medium mb-1">Account</h3>
<p className="text-sm text-muted">{user.email}</p>
{user.name && <p className="text-sm text-muted">{user.name}</p>}
<button
onClick={logout}
className="mt-3 flex items-center gap-2 text-sm text-red-400 hover:text-red-300"
>
<LogOut size={16} />
Sign out
</button>
</div>
)}
{/* Notifications - disabled in MVP */}
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
<div className="flex items-center justify-between">
@@ -68,6 +91,6 @@ export default function SettingsPage() {
<p className="mt-1">A calm, private gratitude log.</p>
</div>
</div>
</div>
</AppShell>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { AppShell } from "@/components/AppShell";
import { WeeklyReflection } from "@/components/WeeklyReflection";
import { FilterPanel } from "@/components/FilterPanel";
import { EntryRow } from "@/components/EntryRow";
@@ -56,7 +57,7 @@ export default function TimelinePage() {
}, [entries, filters]);
return (
<div>
<AppShell>
<header className="mb-6">
<h1 className="text-2xl font-light">{APP_NAME}</h1>
<p className="text-sm text-muted">Timeline</p>
@@ -88,6 +89,6 @@ export default function TimelinePage() {
)}
</>
)}
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { useAuth } from "./AuthProvider";
import { Navigation } from "./Navigation";
export function AppShell({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
if (!user) {
return <>{children}</>;
}
return (
<>
<main className="max-w-lg mx-auto px-4 py-6 pb-24">{children}</main>
<Navigation />
</>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
interface User {
id: string;
email: string;
name: string | null;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
isLoading: true,
logout: async () => {},
refresh: async () => {},
});
export function useAuth() {
return useContext(AuthContext);
}
const PUBLIC_PATHS = ["/login", "/register"];
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const pathname = usePathname();
const fetchUser = useCallback(async () => {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
setUser(data.user);
return data.user;
} catch {
setUser(null);
return null;
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchUser().then((user) => {
if (!user && !PUBLIC_PATHS.includes(pathname)) {
router.push("/login");
}
});
}, [fetchUser, pathname, router]);
const logout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
setUser(null);
router.push("/login");
} catch {
// Ignore errors
}
};
const refresh = async () => {
await fetchUser();
};
// Show nothing while checking auth on protected pages
if (isLoading && !PUBLIC_PATHS.includes(pathname)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-pulse text-muted">Loading...</div>
</div>
);
}
return (
<AuthContext.Provider value={{ user, isLoading, logout, refresh }}>
{children}
</AuthContext.Provider>
);
}

160
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,160 @@
import { cookies } from "next/headers";
import { db, schema } from "./db";
import { eq, and, gt } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import bcrypt from "bcryptjs";
const SESSION_COOKIE = "qt_session";
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export interface AuthUser {
id: string;
email: string;
name: string | null;
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export async function createSession(userId: string): Promise<string> {
const sessionId = uuidv4();
const now = Date.now();
await db.insert(schema.sessions).values({
id: sessionId,
userId,
expiresAt: now + SESSION_DURATION_MS,
createdAt: now,
});
return sessionId;
}
export async function setSessionCookie(sessionId: string) {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SESSION_DURATION_MS / 1000,
path: "/",
});
}
export async function clearSessionCookie() {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getSession(): Promise<AuthUser | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
if (!sessionId) return null;
const now = Date.now();
const result = await db
.select({
sessionId: schema.sessions.id,
userId: schema.users.id,
email: schema.users.email,
name: schema.users.name,
expiresAt: schema.sessions.expiresAt,
})
.from(schema.sessions)
.innerJoin(schema.users, eq(schema.sessions.userId, schema.users.id))
.where(
and(eq(schema.sessions.id, sessionId), gt(schema.sessions.expiresAt, now))
)
.limit(1);
if (result.length === 0) return null;
return {
id: result[0].userId,
email: result[0].email,
name: result[0].name,
};
}
export async function deleteSession(sessionId: string) {
await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId));
}
export async function registerUser(
email: string,
password: string,
name?: string
): Promise<{ user?: AuthUser; error?: string }> {
const existing = await db
.select()
.from(schema.users)
.where(eq(schema.users.email, email.toLowerCase()))
.limit(1);
if (existing.length > 0) {
return { error: "Email already registered" };
}
const userId = uuidv4();
const passwordHash = await hashPassword(password);
const now = Date.now();
await db.insert(schema.users).values({
id: userId,
email: email.toLowerCase(),
passwordHash,
name: name || null,
createdAt: now,
});
return {
user: {
id: userId,
email: email.toLowerCase(),
name: name || null,
},
};
}
export async function loginUser(
email: string,
password: string
): Promise<{ user?: AuthUser; sessionId?: string; error?: string }> {
const users = await db
.select()
.from(schema.users)
.where(eq(schema.users.email, email.toLowerCase()))
.limit(1);
if (users.length === 0) {
return { error: "Invalid email or password" };
}
const user = users[0];
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return { error: "Invalid email or password" };
}
const sessionId = await createSession(user.id);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
},
sessionId,
};
}

View File

@@ -1,7 +1,27 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
name: text("name"),
createdAt: integer("created_at").notNull(), // Unix ms
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at").notNull(), // Unix ms
createdAt: integer("created_at").notNull(), // Unix ms
});
export const entries = sqliteTable("entries", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
date: text("date").notNull(), // YYYY-MM-DD local date
text: text("text").notNull(),
mood: integer("mood"), // 1-5, null if not set
@@ -12,7 +32,10 @@ export const entries = sqliteTable("entries", {
export const tags = sqliteTable("tags", {
id: text("id").primaryKey(),
name: text("name").notNull().unique(), // Normalized lowercase
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(), // Normalized lowercase
createdAt: integer("created_at").notNull(), // Unix ms
});
@@ -30,6 +53,12 @@ export const entryTags = sqliteTable(
);
// Types
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Session = typeof sessions.$inferSelect;
export type NewSession = typeof sessions.$inferInsert;
export type Entry = typeof entries.$inferSelect;
export type NewEntry = typeof entries.$inferInsert;