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
|
## 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.
|
- **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.
|
- **Autosave**: No save button needed. Your entry saves automatically as you type.
|
||||||
- **Timeline**: View all entries in reverse chronological order with filters.
|
- **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`.
|
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
|
### Manual Setup
|
||||||
|
|
||||||
1. Install dependencies:
|
1. Install dependencies:
|
||||||
@@ -77,10 +82,19 @@ The database is created automatically when you run migrations. In Docker, the `.
|
|||||||
|
|
||||||
### Schema
|
### 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
|
- **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
|
- **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
|
## Export
|
||||||
|
|
||||||
Navigate to `/export` or use the Export tab to download your data:
|
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
|
- **Markdown**: Human-readable format, grouped by date, includes mood and tags
|
||||||
- **JSON**: Full data export with all fields and timestamps
|
- **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
|
## Configuration
|
||||||
|
|
||||||
@@ -115,16 +129,39 @@ ports:
|
|||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
- **Styling**: Tailwind CSS 4
|
- **Styling**: Tailwind CSS 4
|
||||||
- **Database**: SQLite with Drizzle ORM
|
- **Database**: SQLite with Drizzle ORM
|
||||||
|
- **Authentication**: bcryptjs for password hashing
|
||||||
- **Icons**: Lucide React
|
- **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
|
## Future Extension Points
|
||||||
|
|
||||||
These features are not implemented but the architecture supports them:
|
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
|
- **LLM summaries**: Integrate with an LLM API to generate monthly reflections
|
||||||
- **Notifications**: Add push notifications for daily reminders
|
- **Notifications**: Add push notifications for daily reminders
|
||||||
- **Import**: Add an import endpoint to restore from JSON exports
|
- **Import**: Add an import endpoint to restore from JSON exports
|
||||||
|
- **OAuth**: Add social login providers
|
||||||
|
|
||||||
## License
|
## 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",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "5116dd80-0292-4345-a403-fd678d148880",
|
"id": "597c7e60-5f2b-4b3c-befb-4c929ccbc227",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"entries": {
|
"entries": {
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"name": "date",
|
"name": "date",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -59,7 +66,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"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": {},
|
"compositePrimaryKeys": {},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
@@ -123,6 +144,58 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"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": {
|
"tags": {
|
||||||
"name": "tags",
|
"name": "tags",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -133,6 +206,13 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -148,11 +228,70 @@
|
|||||||
"autoincrement": false
|
"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": {
|
"indexes": {
|
||||||
"tags_name_unique": {
|
"users_email_unique": {
|
||||||
"name": "tags_name_unique",
|
"name": "users_email_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
"name"
|
"email"
|
||||||
],
|
],
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1769219562573,
|
"when": 1769235451953,
|
||||||
"tag": "0000_busy_earthquake",
|
"tag": "0000_flat_captain_marvel",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "quietthanks",
|
"name": "quietthanks",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
@@ -2417,6 +2419,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/better-sqlite3": {
|
||||||
"version": "7.6.13",
|
"version": "7.6.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
@@ -3345,6 +3354,15 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"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": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.6.2",
|
"version": "12.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { db, schema } from "@/lib/db";
|
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 type { UpdateEntryRequest, EntryWithTags } from "@/lib/types";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
@@ -10,13 +11,23 @@ interface RouteParams {
|
|||||||
|
|
||||||
// GET /api/entries/[id] - Get single entry
|
// GET /api/entries/[id] - Get single entry
|
||||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
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;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await db
|
const entry = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.entries)
|
.from(schema.entries)
|
||||||
.where(eq(schema.entries.id, id))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.entries.id, id),
|
||||||
|
eq(schema.entries.userId, user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (entry.length === 0) {
|
if (entry.length === 0) {
|
||||||
@@ -43,6 +54,11 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
// PATCH /api/entries/[id] - Update entry
|
// PATCH /api/entries/[id] - Update entry
|
||||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
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;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -52,7 +68,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.entries)
|
.from(schema.entries)
|
||||||
.where(eq(schema.entries.id, id))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.entries.id, id),
|
||||||
|
eq(schema.entries.userId, user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
@@ -82,7 +103,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
let tag = await db
|
let tag = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.tags)
|
.from(schema.tags)
|
||||||
.where(eq(schema.tags.name, normalizedName))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.tags.userId, user.id),
|
||||||
|
eq(schema.tags.name, normalizedName)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let tagId: string;
|
let tagId: string;
|
||||||
@@ -90,6 +116,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
tagId = uuidv4();
|
tagId = uuidv4();
|
||||||
await db.insert(schema.tags).values({
|
await db.insert(schema.tags).values({
|
||||||
id: tagId,
|
id: tagId,
|
||||||
|
userId: user.id,
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
@@ -131,13 +158,23 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
// DELETE /api/entries/[id] - Delete entry
|
// DELETE /api/entries/[id] - Delete entry
|
||||||
export async function DELETE(_request: NextRequest, { params }: RouteParams) {
|
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;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.entries)
|
.from(schema.entries)
|
||||||
.where(eq(schema.entries.id, id))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.entries.id, id),
|
||||||
|
eq(schema.entries.userId, user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { db, schema } from "@/lib/db";
|
import { db, schema } from "@/lib/db";
|
||||||
import { eq, desc, and, inArray, gte } from "drizzle-orm";
|
import { eq, desc, and, inArray, gte } from "drizzle-orm";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
import type { CreateEntryRequest, EntryWithTags } from "@/lib/types";
|
import type { CreateEntryRequest, EntryWithTags } from "@/lib/types";
|
||||||
|
|
||||||
// GET /api/entries - List entries with optional filters
|
// GET /api/entries - List entries with optional filters
|
||||||
export async function GET(request: NextRequest) {
|
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 { searchParams } = new URL(request.url);
|
||||||
const moods = searchParams.get("moods")?.split(",").map(Number).filter(Boolean);
|
const moods = searchParams.get("moods")?.split(",").map(Number).filter(Boolean);
|
||||||
const tagId = searchParams.get("tagId");
|
const tagId = searchParams.get("tagId");
|
||||||
@@ -13,8 +19,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const since = searchParams.get("since"); // YYYY-MM-DD
|
const since = searchParams.get("since"); // YYYY-MM-DD
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build conditions
|
// Build conditions - always filter by userId
|
||||||
const conditions = [];
|
const conditions = [eq(schema.entries.userId, user.id)];
|
||||||
if (moods && moods.length > 0) {
|
if (moods && moods.length > 0) {
|
||||||
conditions.push(inArray(schema.entries.mood, moods));
|
conditions.push(inArray(schema.entries.mood, moods));
|
||||||
}
|
}
|
||||||
@@ -31,7 +37,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const entries = await db
|
const entries = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.entries)
|
.from(schema.entries)
|
||||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
.where(and(...conditions))
|
||||||
.orderBy(desc(schema.entries.date), desc(schema.entries.createdAt));
|
.orderBy(desc(schema.entries.date), desc(schema.entries.createdAt));
|
||||||
|
|
||||||
// Get tags for each entry
|
// Get tags for each entry
|
||||||
@@ -62,6 +68,11 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// POST /api/entries - Create or update today's entry
|
// POST /api/entries - Create or update today's entry
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const user = await getSession();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: CreateEntryRequest = await request.json();
|
const body: CreateEntryRequest = await request.json();
|
||||||
const { date, text, mood, roughDay, tagNames } = body;
|
const { date, text, mood, roughDay, tagNames } = body;
|
||||||
@@ -72,11 +83,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const now = Date.now();
|
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
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.entries)
|
.from(schema.entries)
|
||||||
.where(eq(schema.entries.date, date))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.entries.userId, user.id),
|
||||||
|
eq(schema.entries.date, date)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let entryId: string;
|
let entryId: string;
|
||||||
@@ -98,6 +114,7 @@ export async function POST(request: NextRequest) {
|
|||||||
entryId = uuidv4();
|
entryId = uuidv4();
|
||||||
await db.insert(schema.entries).values({
|
await db.insert(schema.entries).values({
|
||||||
id: entryId,
|
id: entryId,
|
||||||
|
userId: user.id,
|
||||||
date,
|
date,
|
||||||
text,
|
text,
|
||||||
mood: mood ?? null,
|
mood: mood ?? null,
|
||||||
@@ -117,11 +134,16 @@ export async function POST(request: NextRequest) {
|
|||||||
const normalizedName = name.toLowerCase().trim();
|
const normalizedName = name.toLowerCase().trim();
|
||||||
if (!normalizedName) continue;
|
if (!normalizedName) continue;
|
||||||
|
|
||||||
// Find or create tag
|
// Find or create tag for this user
|
||||||
let tag = await db
|
let tag = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.tags)
|
.from(schema.tags)
|
||||||
.where(eq(schema.tags.name, normalizedName))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.tags.userId, user.id),
|
||||||
|
eq(schema.tags.name, normalizedName)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let tagId: string;
|
let tagId: string;
|
||||||
@@ -129,6 +151,7 @@ export async function POST(request: NextRequest) {
|
|||||||
tagId = uuidv4();
|
tagId = uuidv4();
|
||||||
await db.insert(schema.tags).values({
|
await db.insert(schema.tags).values({
|
||||||
id: tagId,
|
id: tagId,
|
||||||
|
userId: user.id,
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db, schema } from "@/lib/db";
|
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 type { EntryWithTags, ExportFormat } from "@/lib/types";
|
||||||
import { MOOD_LABELS, APP_NAME } from "@/lib/constants";
|
import { MOOD_LABELS, APP_NAME } from "@/lib/constants";
|
||||||
import { formatDateLong } from "@/lib/utils/date";
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const user = await getSession();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const format: ExportFormat = body.format || "markdown";
|
const format: ExportFormat = body.format || "markdown";
|
||||||
|
|
||||||
// Fetch all entries with tags
|
// Fetch all entries with tags for this user
|
||||||
const entries = await db
|
const entries = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.entries)
|
.from(schema.entries)
|
||||||
|
.where(eq(schema.entries.userId, user.id))
|
||||||
.orderBy(desc(schema.entries.date));
|
.orderBy(desc(schema.entries.date));
|
||||||
|
|
||||||
const entriesWithTags: EntryWithTags[] = [];
|
const entriesWithTags: EntryWithTags[] = [];
|
||||||
@@ -36,6 +43,7 @@ export async function POST(request: NextRequest) {
|
|||||||
{
|
{
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
appName: APP_NAME,
|
appName: APP_NAME,
|
||||||
|
user: { email: user.email, name: user.name },
|
||||||
entries: entriesWithTags,
|
entries: entriesWithTags,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -52,7 +60,8 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Markdown export
|
// Markdown export
|
||||||
let markdown = `# ${APP_NAME} Export\n\n`;
|
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`;
|
markdown += `---\n\n`;
|
||||||
|
|
||||||
// Group by date
|
// Group by date
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db, schema } from "@/lib/db";
|
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)
|
// GET /api/tags - Get tags (recent or search)
|
||||||
export async function GET(request: NextRequest) {
|
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 { searchParams } = new URL(request.url);
|
||||||
const search = searchParams.get("search");
|
const search = searchParams.get("search");
|
||||||
const limit = parseInt(searchParams.get("limit") || "10", 10);
|
const limit = parseInt(searchParams.get("limit") || "10", 10);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (search) {
|
if (search) {
|
||||||
// Search tags by name
|
// Search tags by name for this user
|
||||||
const tags = await db
|
const tags = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.tags)
|
.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);
|
.limit(limit);
|
||||||
|
|
||||||
return NextResponse.json(tags);
|
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
|
const tagsWithCount = await db
|
||||||
.select({
|
.select({
|
||||||
id: schema.tags.id,
|
id: schema.tags.id,
|
||||||
@@ -30,6 +41,7 @@ export async function GET(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
.from(schema.tags)
|
.from(schema.tags)
|
||||||
.leftJoin(schema.entryTags, sql`${schema.tags.id} = ${schema.entryTags.tagId}`)
|
.leftJoin(schema.entryTags, sql`${schema.tags.id} = ${schema.entryTags.tagId}`)
|
||||||
|
.where(eq(schema.tags.userId, user.id))
|
||||||
.groupBy(schema.tags.id)
|
.groupBy(schema.tags.id)
|
||||||
.orderBy(desc(sql`use_count`), desc(schema.tags.createdAt))
|
.orderBy(desc(sql`use_count`), desc(schema.tags.createdAt))
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { use, useState, useEffect, useCallback } from "react";
|
import { use, useState, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { EntryForm } from "@/components/EntryForm";
|
import { EntryForm } from "@/components/EntryForm";
|
||||||
import { ToastContainer } from "@/components/Toast";
|
import { ToastContainer } from "@/components/Toast";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
@@ -78,7 +79,7 @@ export default function EntryPage({ params }: EntryPageProps) {
|
|||||||
}, [entry, id, router, showToast]);
|
}, [entry, id, router, showToast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppShell>
|
||||||
<header className="flex items-center justify-between mb-6">
|
<header className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
@@ -133,6 +134,6 @@ export default function EntryPage({ params }: EntryPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||||
</div>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { Download, FileJson, FileText, Loader2 } from "lucide-react";
|
import { Download, FileJson, FileText, Loader2 } from "lucide-react";
|
||||||
import { APP_NAME } from "@/lib/constants";
|
import { APP_NAME } from "@/lib/constants";
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export default function ExportPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppShell>
|
||||||
<header className="mb-8">
|
<header className="mb-8">
|
||||||
<h1 className="text-2xl font-light">{APP_NAME}</h1>
|
<h1 className="text-2xl font-light">{APP_NAME}</h1>
|
||||||
<p className="text-sm text-muted">Export</p>
|
<p className="text-sm text-muted">Export</p>
|
||||||
@@ -97,6 +98,6 @@ export default function ExportPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Navigation } from "@/components/Navigation";
|
|
||||||
import { APP_NAME } from "@/lib/constants";
|
import { APP_NAME } from "@/lib/constants";
|
||||||
|
import { AuthProvider } from "@/components/AuthProvider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: APP_NAME,
|
title: APP_NAME,
|
||||||
@@ -31,9 +31,8 @@ export default function RootLayout({
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased min-h-screen pb-20">
|
<body className="antialiased min-h-screen">
|
||||||
<main className="max-w-lg mx-auto px-4 py-6">{children}</main>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
<Navigation />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 { EntryForm } from "@/components/EntryForm";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { APP_NAME } from "@/lib/constants";
|
import { APP_NAME } from "@/lib/constants";
|
||||||
import { formatDate, getLocalDate } from "@/lib/utils/date";
|
import { formatDate, getLocalDate } from "@/lib/utils/date";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight, LogOut } from "lucide-react";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const today = getLocalDate();
|
const today = getLocalDate();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppShell>
|
||||||
<header className="mb-8">
|
<header className="mb-8 flex items-start justify-between">
|
||||||
<h1 className="text-2xl font-light mb-1">{APP_NAME}</h1>
|
<div>
|
||||||
<p className="text-sm text-muted">{formatDate(today)}</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
<EntryForm />
|
<EntryForm />
|
||||||
@@ -25,6 +42,6 @@ export default function HomePage() {
|
|||||||
<ChevronRight size={20} />
|
<ChevronRight size={20} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 { APP_NAME } from "@/lib/constants";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppShell>
|
||||||
<header className="mb-8">
|
<header className="mb-8">
|
||||||
<h1 className="text-2xl font-light">{APP_NAME}</h1>
|
<h1 className="text-2xl font-light">{APP_NAME}</h1>
|
||||||
<p className="text-sm text-muted">Settings</p>
|
<p className="text-sm text-muted">Settings</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Notifications - disabled in MVP */}
|
||||||
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
|
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<p className="mt-1">A calm, private gratitude log.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { WeeklyReflection } from "@/components/WeeklyReflection";
|
import { WeeklyReflection } from "@/components/WeeklyReflection";
|
||||||
import { FilterPanel } from "@/components/FilterPanel";
|
import { FilterPanel } from "@/components/FilterPanel";
|
||||||
import { EntryRow } from "@/components/EntryRow";
|
import { EntryRow } from "@/components/EntryRow";
|
||||||
@@ -56,7 +57,7 @@ export default function TimelinePage() {
|
|||||||
}, [entries, filters]);
|
}, [entries, filters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppShell>
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<h1 className="text-2xl font-light">{APP_NAME}</h1>
|
<h1 className="text-2xl font-light">{APP_NAME}</h1>
|
||||||
<p className="text-sm text-muted">Timeline</p>
|
<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";
|
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", {
|
export const entries = sqliteTable("entries", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
date: text("date").notNull(), // YYYY-MM-DD local date
|
date: text("date").notNull(), // YYYY-MM-DD local date
|
||||||
text: text("text").notNull(),
|
text: text("text").notNull(),
|
||||||
mood: integer("mood"), // 1-5, null if not set
|
mood: integer("mood"), // 1-5, null if not set
|
||||||
@@ -12,7 +32,10 @@ export const entries = sqliteTable("entries", {
|
|||||||
|
|
||||||
export const tags = sqliteTable("tags", {
|
export const tags = sqliteTable("tags", {
|
||||||
id: text("id").primaryKey(),
|
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
|
createdAt: integer("created_at").notNull(), // Unix ms
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,6 +53,12 @@ export const entryTags = sqliteTable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Types
|
// 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 Entry = typeof entries.$inferSelect;
|
||||||
export type NewEntry = typeof entries.$inferInsert;
|
export type NewEntry = typeof entries.$inferInsert;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user