mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
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:
43
README.md
43
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
|
||||
|
||||
|
||||
@@ -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`);
|
||||
45
drizzle/0000_flat_captain_marvel.sql
Normal file
45
drizzle/0000_flat_captain_marvel.sql
Normal 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`);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
18
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
28
src/app/api/auth/login/route.ts
Normal file
28
src/app/api/auth/login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/auth/logout/route.ts
Normal file
12
src/app/api/auth/logout/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
17
src/app/api/auth/me/route.ts
Normal file
17
src/app/api/auth/me/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
40
src/app/api/auth/register/route.ts
Normal file
40
src/app/api/auth/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
108
src/app/login/page.tsx
Normal 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't have an account?{" "}
|
||||
<Link href="/register" className="text-accent hover:underline">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AppShell>
|
||||
<header className="mb-8 flex items-start justify-between">
|
||||
<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>
|
||||
</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
124
src/app/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
19
src/components/AppShell.tsx
Normal file
19
src/components/AppShell.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
src/components/AuthProvider.tsx
Normal file
88
src/components/AuthProvider.tsx
Normal 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
160
src/lib/auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user