diff --git a/README.md b/README.md index 90d4c07..4cd616b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ A calm, private gratitude and mood log. No streaks, no gamification—just a sim ## Features +- **User accounts**: Each user has their own private entries and tags - **Quick check-in**: One prompt, optional mood, optional tags. Entry capture takes 10-20 seconds. - **Autosave**: No save button needed. Your entry saves automatically as you type. - **Timeline**: View all entries in reverse chronological order with filters. @@ -22,6 +23,10 @@ docker compose up -d The app will be available at `http://localhost:6124`. +1. Navigate to the app and click "Create one" to register +2. Enter your email, password, and optional name +3. Start logging your gratitude! + ### Manual Setup 1. Install dependencies: @@ -77,10 +82,19 @@ The database is created automatically when you run migrations. In Docker, the `. ### Schema +- **users**: User accounts with email and password hash +- **sessions**: Session tokens for authentication (30-day expiry) - **entries**: Main gratitude entries with date, text, optional mood (1-5), rough day flag, and timestamps -- **tags**: Normalized tag names +- **tags**: Normalized tag names per user - **entry_tags**: Junction table linking entries to tags +## Authentication + +- Session-based authentication with HTTP-only cookies +- Passwords are hashed with bcrypt +- Sessions expire after 30 days +- Each user can only see their own entries and tags + ## Export Navigate to `/export` or use the Export tab to download your data: @@ -88,7 +102,7 @@ Navigate to `/export` or use the Export tab to download your data: - **Markdown**: Human-readable format, grouped by date, includes mood and tags - **JSON**: Full data export with all fields and timestamps -Exports include all entries regardless of filters. +Exports include all entries for the logged-in user. ## Configuration @@ -115,16 +129,39 @@ ports: - **Language**: TypeScript - **Styling**: Tailwind CSS 4 - **Database**: SQLite with Drizzle ORM +- **Authentication**: bcryptjs for password hashing - **Icons**: Lucide React +## API Routes + +### Authentication +- `POST /api/auth/register` - Create new account +- `POST /api/auth/login` - Sign in +- `POST /api/auth/logout` - Sign out +- `GET /api/auth/me` - Get current user + +### Entries +- `GET /api/entries` - List entries (with optional filters) +- `POST /api/entries` - Create or update entry for a date +- `GET /api/entries/[id]` - Get single entry +- `PATCH /api/entries/[id]` - Update entry +- `DELETE /api/entries/[id]` - Delete entry + +### Tags +- `GET /api/tags` - Get recent/search tags + +### Export +- `POST /api/export` - Export all entries (markdown or json) + ## Future Extension Points These features are not implemented but the architecture supports them: -- **Cloud sync**: Add authentication and a sync service to enable cross-device access +- **Cloud sync**: Add a sync service to enable cross-device access - **LLM summaries**: Integrate with an LLM API to generate monthly reflections - **Notifications**: Add push notifications for daily reminders - **Import**: Add an import endpoint to restore from JSON exports +- **OAuth**: Add social login providers ## License diff --git a/drizzle/0000_busy_earthquake.sql b/drizzle/0000_busy_earthquake.sql deleted file mode 100644 index 321bdc8..0000000 --- a/drizzle/0000_busy_earthquake.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE `entries` ( - `id` text PRIMARY KEY NOT NULL, - `date` text NOT NULL, - `text` text NOT NULL, - `mood` integer, - `rough_day` integer DEFAULT 0 NOT NULL, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE `entry_tags` ( - `entry_id` text NOT NULL, - `tag_id` text NOT NULL, - PRIMARY KEY(`entry_id`, `tag_id`), - FOREIGN KEY (`entry_id`) REFERENCES `entries`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE TABLE `tags` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `created_at` integer NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`); \ No newline at end of file diff --git a/drizzle/0000_flat_captain_marvel.sql b/drizzle/0000_flat_captain_marvel.sql new file mode 100644 index 0000000..e682e74 --- /dev/null +++ b/drizzle/0000_flat_captain_marvel.sql @@ -0,0 +1,45 @@ +CREATE TABLE `entries` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `date` text NOT NULL, + `text` text NOT NULL, + `mood` integer, + `rough_day` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `entry_tags` ( + `entry_id` text NOT NULL, + `tag_id` text NOT NULL, + PRIMARY KEY(`entry_id`, `tag_id`), + FOREIGN KEY (`entry_id`) REFERENCES `entries`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `tags` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `password_hash` text NOT NULL, + `name` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index e2d92b1..cb1516d 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "5116dd80-0292-4345-a403-fd678d148880", + "id": "597c7e60-5f2b-4b3c-befb-4c929ccbc227", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "entries": { @@ -14,6 +14,13 @@ "notNull": true, "autoincrement": false }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "date": { "name": "date", "type": "text", @@ -59,7 +66,21 @@ } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "entries_user_id_users_id_fk": { + "name": "entries_user_id_users_id_fk", + "tableFrom": "entries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} @@ -123,6 +144,58 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "tags": { "name": "tags", "columns": { @@ -133,6 +206,13 @@ "notNull": true, "autoincrement": false }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "name": { "name": "name", "type": "text", @@ -148,11 +228,70 @@ "autoincrement": false } }, + "indexes": {}, + "foreignKeys": { + "tags_user_id_users_id_fk": { + "name": "tags_user_id_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, "indexes": { - "tags_name_unique": { - "name": "tags_name_unique", + "users_email_unique": { + "name": "users_email_unique", "columns": [ - "name" + "email" ], "isUnique": true } diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ca08a71..b0f526d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1769219562573, - "tag": "0000_busy_earthquake", + "when": 1769235451953, + "tag": "0000_flat_captain_marvel", "breakpoints": true } ] diff --git a/package-lock.json b/package-lock.json index 7acdecd..7e27355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "quietthanks", "version": "0.1.0", "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "drizzle-orm": "^0.45.1", "lucide-react": "^0.562.0", @@ -18,6 +19,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", @@ -2417,6 +2419,13 @@ "tslib": "^2.4.0" } }, + "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/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -3345,6 +3354,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "12.6.2", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", diff --git a/package.json b/package.json index 1275626..43081ec 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "drizzle-orm": "^0.45.1", "lucide-react": "^0.562.0", @@ -22,6 +23,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..0e048f4 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 }); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..e6dad90 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -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 }); + } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..ea492ea --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -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 }); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..f9d5c0e --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/entries/[id]/route.ts b/src/app/api/entries/[id]/route.ts index d251de2..8f9004a 100644 --- a/src/app/api/entries/[id]/route.ts +++ b/src/app/api/entries/[id]/route.ts @@ -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) { diff --git a/src/app/api/entries/route.ts b/src/app/api/entries/route.ts index 45f2dba..050d48e 100644 --- a/src/app/api/entries/route.ts +++ b/src/app/api/entries/route.ts @@ -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, }); diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 3c87bcd..f41df68 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -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 diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts index ff4a57e..6269784 100644 --- a/src/app/api/tags/route.ts +++ b/src/app/api/tags/route.ts @@ -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); diff --git a/src/app/entry/[id]/page.tsx b/src/app/entry/[id]/page.tsx index ea1d240..526e355 100644 --- a/src/app/entry/[id]/page.tsx +++ b/src/app/entry/[id]/page.tsx @@ -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 ( -
+
-
+ ); } diff --git a/src/app/export/page.tsx b/src/app/export/page.tsx index ef88390..78479a5 100644 --- a/src/app/export/page.tsx +++ b/src/app/export/page.tsx @@ -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 ( -
+

{APP_NAME}

Export

@@ -97,6 +98,6 @@ export default function ExportPage() {
- + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 37c3657..2c27cea 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ - -
{children}
- + + {children} ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..9b0798e --- /dev/null +++ b/src/app/login/page.tsx @@ -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 ( +
+
+
+

{APP_NAME}

+

Sign in to your account

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+ +

+ Don't have an account?{" "} + + Create one + +

+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 51d3499..224c685 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
-
-

{APP_NAME}

-

{formatDate(today)}

+ +
+
+

{APP_NAME}

+

{formatDate(today)}

+
+ {user && ( + + )}
@@ -25,6 +42,6 @@ export default function HomePage() {
- + ); } diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..bb7b09f --- /dev/null +++ b/src/app/register/page.tsx @@ -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 ( +
+
+
+

{APP_NAME}

+

Create your account

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index aa4bef3..54d5854 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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 ( -
+

{APP_NAME}

Settings

+ {/* Account info */} + {user && ( +
+

Account

+

{user.email}

+ {user.name &&

{user.name}

} + +
+ )} + {/* Notifications - disabled in MVP */}
@@ -68,6 +91,6 @@ export default function SettingsPage() {

A calm, private gratitude log.

-
+
); } diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx index b66092b..4fa1ad4 100644 --- a/src/app/timeline/page.tsx +++ b/src/app/timeline/page.tsx @@ -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 ( -
+

{APP_NAME}

Timeline

@@ -88,6 +89,6 @@ export default function TimelinePage() { )} )} -
+ ); } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..b24b7f0 --- /dev/null +++ b/src/components/AppShell.tsx @@ -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 ( + <> +
{children}
+ + + ); +} diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx new file mode 100644 index 0000000..532df51 --- /dev/null +++ b/src/components/AuthProvider.tsx @@ -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; + refresh: () => Promise; +} + +const AuthContext = createContext({ + 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(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 ( +
+
Loading...
+
+ ); + } + + return ( + + {children} + + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..a8c5231 --- /dev/null +++ b/src/lib/auth.ts @@ -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 { + return bcrypt.hash(password, 10); +} + +export async function verifyPassword( + password: string, + hash: string +): Promise { + return bcrypt.compare(password, hash); +} + +export async function createSession(userId: string): Promise { + 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 { + 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, + }; +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 2543173..0ff6fa8 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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;