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

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

View File

@@ -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`);

View File

@@ -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`);

View File

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

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1769219562573,
"tag": "0000_busy_earthquake",
"when": 1769235451953,
"tag": "0000_flat_captain_marvel",
"breakpoints": true
}
]

18
package-lock.json generated
View File

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

View File

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

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;