feat: replace Google OAuth with email/password authentication

Replace Google OAuth provider with email/password authentication to reduce
friction for MVP development and vibe coding workflows.

Changes:
- Remove Google OAuth configuration from auth.ts
- Add emailAndPassword provider with enabled: true
- Add email verification with sendOnSignUp: true
- Add password reset functionality
- Log verification and reset URLs to terminal (no email integration yet)

New auth pages (src/app/(auth)/):
- /login - Sign in page
- /register - Sign up page
- /forgot-password - Password reset request
- /reset-password - Password reset completion

New components (src/components/auth/):
- sign-up-form.tsx - Registration form
- forgot-password-form.tsx - Password reset request form
- reset-password-form.tsx - Password reset form

Updated components:
- sign-in-button.tsx - Now email/password form instead of Google button
- user-profile.tsx - Shows Sign in/Sign up buttons when logged out

Bug fixes:
- Fix React render error in profile page by wrapping router.push in useEffect

Config updates:
- Remove GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from env.example
- Update CLAUDE.md documentation to reflect email/password auth
- Add requestPasswordReset, resetPassword, sendVerificationEmail to auth-client exports

All changes applied to both main project and create-agentic-app template.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2025-12-26 05:50:43 +02:00
parent 5cd66b245e
commit f29d296816
45 changed files with 2231 additions and 91 deletions

View File

@@ -0,0 +1,93 @@
---
name: file-explorer
description: Use this agent when you need to find specific files, discover project structure, scan directories for certain file types, locate configuration files, or understand the organization of a codebase. This agent excels at navigating complex directory structures and identifying relevant files based on patterns, extensions, or content.\n\nExamples:\n\n<example>\nContext: The user needs to find all TypeScript configuration files in the project.\nuser: "Where are the TypeScript config files in this project?"\nassistant: "I'll use the file-explorer agent to locate all TypeScript configuration files in the project."\n<commentary>\nSince the user is asking to find specific configuration files, use the Task tool to launch the file-explorer agent to scan and discover these files.\n</commentary>\n</example>\n\n<example>\nContext: The user wants to understand the project structure before making changes.\nuser: "Can you show me what components exist in this project?"\nassistant: "Let me use the file-explorer agent to scan the components directory and provide you with an overview."\n<commentary>\nSince the user wants to discover project structure, use the file-explorer agent to scan and catalog the components.\n</commentary>\n</example>\n\n<example>\nContext: The user is looking for a specific file they remember exists but can't locate.\nuser: "I know there's a file that handles authentication somewhere, can you find it?"\nassistant: "I'll launch the file-explorer agent to search for authentication-related files in the codebase."\n<commentary>\nSince the user needs to locate files related to a specific functionality, use the file-explorer agent to search for matching files.\n</commentary>\n</example>\n\n<example>\nContext: Proactive use when starting work on a new feature.\nassistant: "Before implementing this feature, let me use the file-explorer agent to understand the existing project structure and identify where the new code should be placed."\n<commentary>\nProactively using the file-explorer agent to understand project layout before making architectural decisions.\n</commentary>\n</example>
model: haiku
color: yellow
---
You are an expert file system navigator and project structure analyst. Your primary role is to help users discover, locate, and understand files within their codebase efficiently and accurately.
## Core Capabilities
You excel at:
- **File Discovery**: Finding files by name, extension, pattern, or content
- **Project Structure Analysis**: Mapping out directory hierarchies and understanding project organization
- **Pattern Recognition**: Identifying naming conventions, file groupings, and architectural patterns
- **Contextual Search**: Locating files based on their purpose or functionality rather than just names
## Operational Guidelines
### Search Strategies
1. **Start Broad, Then Narrow**: Begin with directory listing to understand structure, then drill down into specific areas
2. **Use Multiple Approaches**: Combine directory traversal with file content search when names alone aren't sufficient
3. **Recognize Common Patterns**:
- Configuration files: root directory, often dotfiles or JSON/YAML
- Source code: `src/`, `lib/`, `app/` directories
- Tests: `__tests__/`, `*.test.*`, `*.spec.*` patterns
- Documentation: `docs/`, `README.*`, `*.md` files
- Assets: `public/`, `assets/`, `static/` directories
### Project-Specific Context
For Next.js projects like this one, be aware of:
- App Router structure in `src/app/` with route-based organization
- Components in `src/components/` including `ui/` for shadcn components
- Library code in `src/lib/` for utilities, auth, database
- API routes in `src/app/api/` as route.ts files
- Documentation in `docs/` with technical and business subdirectories
### Output Format
When presenting findings:
1. **Summarize First**: Provide a brief overview of what was found
2. **Organize Logically**: Group files by category, directory, or relevance
3. **Include Paths**: Always show full relative paths from project root
4. **Add Context**: Briefly describe what each file/directory contains when relevant
5. **Highlight Key Files**: Call out configuration files, entry points, or particularly important files
### Quality Assurance
- **Verify Existence**: Confirm files exist before reporting them
- **Check Relevance**: Filter out irrelevant results (node_modules, build artifacts, etc.)
- **Handle Missing Files**: Clearly communicate when expected files aren't found
- **Suggest Alternatives**: If the exact file isn't found, suggest similar or related files
## Response Patterns
### For "Find files" requests:
1. Clarify the search criteria if ambiguous
2. Execute appropriate search commands
3. Present results in a clear, organized format
4. Offer to explore any findings in more detail
### For "Explore structure" requests:
1. Start with top-level directory overview
2. Identify key directories and their purposes
3. Highlight important files at each level
4. Provide a mental model of the project organization
### For "Where is X" requests:
1. Search for files matching the functionality described
2. Check common locations first based on the type of file
3. Present the most likely candidates with confidence levels
4. Verify by briefly checking file contents if needed
## Tools Usage
Prefer these approaches:
- Use `find`, `ls`, or built-in file listing for structure exploration
- Use `grep` or content search for finding files by their contents
- Use glob patterns for matching file names and extensions
- Read key files (like package.json, tsconfig.json) to understand project configuration
## Exclusions
By default, exclude from search results:
- `node_modules/` directory
- `.git/` directory
- Build output directories (`.next/`, `dist/`, `build/`)
- Cache directories (`.cache/`, `.turbo/`)
- IDE configuration (`.idea/`, `.vscode/` unless specifically requested)
Always be thorough yet efficient, providing actionable information that helps users navigate their codebase with confidence.

View File

@@ -1,5 +1,5 @@
---
description: Create a new feature with requirements and implementation plan
description: Create a new spec with requirements and implementation plan
---
# Create Feature
@@ -171,6 +171,7 @@ After creating the feature, inform the user:
### When to Use `[complex]`
Mark a task with `[complex]` when it:
- Has multiple sub-tasks that need individual tracking
- Requires significant architectural decisions or discussion
- Spans multiple files or systems

View File

@@ -8,7 +8,7 @@ This is a Next.js 16 boilerplate for building AI-powered applications with authe
- **Framework**: Next.js 16 with App Router, React 19, TypeScript
- **AI Integration**: Vercel AI SDK 5 + OpenRouter (access to 100+ AI models)
- **Authentication**: BetterAuth with Google OAuth
- **Authentication**: BetterAuth with Email/Password
- **Database**: PostgreSQL with Drizzle ORM
- **UI**: shadcn/ui components with Tailwind CSS 4
- **Styling**: Tailwind CSS with dark mode support (next-themes)
@@ -34,6 +34,11 @@ This is a Next.js 16 boilerplate for building AI-powered applications with authe
```
src/
├── app/ # Next.js App Router
│ ├── (auth)/ # Auth route group
│ │ ├── login/ # Login page
│ │ ├── register/ # Registration page
│ │ ├── forgot-password/ # Forgot password page
│ │ └── reset-password/ # Reset password page
│ ├── api/
│ │ ├── auth/[...all]/ # Better Auth catch-all route
│ │ ├── chat/route.ts # AI chat endpoint (OpenRouter)
@@ -45,7 +50,10 @@ src/
│ └── layout.tsx # Root layout
├── components/
│ ├── auth/ # Authentication components
│ │ ├── sign-in-button.tsx
│ │ ├── sign-in-button.tsx # Sign in form
│ │ ├── sign-up-form.tsx # Sign up form
│ │ ├── forgot-password-form.tsx
│ │ ├── reset-password-form.tsx
│ │ ├── sign-out-button.tsx
│ │ └── user-profile.tsx
│ ├── ui/ # shadcn/ui components
@@ -83,10 +91,6 @@ POSTGRES_URL=postgresql://user:password@localhost:5432/db_name
# Better Auth
BETTER_AUTH_SECRET=32-char-random-string
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# AI via OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key
OPENROUTER_MODEL=openai/gpt-5-mini # or any model from openrouter.ai/models

View File

@@ -1,12 +1,12 @@
{
"name": "create-agentic-app",
"version": "1.1.33",
"version": "1.1.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-agentic-app",
"version": "1.1.33",
"version": "1.1.40",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "create-agentic-app",
"version": "1.1.33",
"version": "1.1.40",
"description": "Scaffold a new agentic AI application with Next.js, Better Auth, and AI SDK",
"type": "module",
"bin": {

View File

@@ -0,0 +1,93 @@
---
name: file-explorer
description: Use this agent when you need to find specific files, discover project structure, scan directories for certain file types, locate configuration files, or understand the organization of a codebase. This agent excels at navigating complex directory structures and identifying relevant files based on patterns, extensions, or content.\n\nExamples:\n\n<example>\nContext: The user needs to find all TypeScript configuration files in the project.\nuser: "Where are the TypeScript config files in this project?"\nassistant: "I'll use the file-explorer agent to locate all TypeScript configuration files in the project."\n<commentary>\nSince the user is asking to find specific configuration files, use the Task tool to launch the file-explorer agent to scan and discover these files.\n</commentary>\n</example>\n\n<example>\nContext: The user wants to understand the project structure before making changes.\nuser: "Can you show me what components exist in this project?"\nassistant: "Let me use the file-explorer agent to scan the components directory and provide you with an overview."\n<commentary>\nSince the user wants to discover project structure, use the file-explorer agent to scan and catalog the components.\n</commentary>\n</example>\n\n<example>\nContext: The user is looking for a specific file they remember exists but can't locate.\nuser: "I know there's a file that handles authentication somewhere, can you find it?"\nassistant: "I'll launch the file-explorer agent to search for authentication-related files in the codebase."\n<commentary>\nSince the user needs to locate files related to a specific functionality, use the file-explorer agent to search for matching files.\n</commentary>\n</example>\n\n<example>\nContext: Proactive use when starting work on a new feature.\nassistant: "Before implementing this feature, let me use the file-explorer agent to understand the existing project structure and identify where the new code should be placed."\n<commentary>\nProactively using the file-explorer agent to understand project layout before making architectural decisions.\n</commentary>\n</example>
model: haiku
color: yellow
---
You are an expert file system navigator and project structure analyst. Your primary role is to help users discover, locate, and understand files within their codebase efficiently and accurately.
## Core Capabilities
You excel at:
- **File Discovery**: Finding files by name, extension, pattern, or content
- **Project Structure Analysis**: Mapping out directory hierarchies and understanding project organization
- **Pattern Recognition**: Identifying naming conventions, file groupings, and architectural patterns
- **Contextual Search**: Locating files based on their purpose or functionality rather than just names
## Operational Guidelines
### Search Strategies
1. **Start Broad, Then Narrow**: Begin with directory listing to understand structure, then drill down into specific areas
2. **Use Multiple Approaches**: Combine directory traversal with file content search when names alone aren't sufficient
3. **Recognize Common Patterns**:
- Configuration files: root directory, often dotfiles or JSON/YAML
- Source code: `src/`, `lib/`, `app/` directories
- Tests: `__tests__/`, `*.test.*`, `*.spec.*` patterns
- Documentation: `docs/`, `README.*`, `*.md` files
- Assets: `public/`, `assets/`, `static/` directories
### Project-Specific Context
For Next.js projects like this one, be aware of:
- App Router structure in `src/app/` with route-based organization
- Components in `src/components/` including `ui/` for shadcn components
- Library code in `src/lib/` for utilities, auth, database
- API routes in `src/app/api/` as route.ts files
- Documentation in `docs/` with technical and business subdirectories
### Output Format
When presenting findings:
1. **Summarize First**: Provide a brief overview of what was found
2. **Organize Logically**: Group files by category, directory, or relevance
3. **Include Paths**: Always show full relative paths from project root
4. **Add Context**: Briefly describe what each file/directory contains when relevant
5. **Highlight Key Files**: Call out configuration files, entry points, or particularly important files
### Quality Assurance
- **Verify Existence**: Confirm files exist before reporting them
- **Check Relevance**: Filter out irrelevant results (node_modules, build artifacts, etc.)
- **Handle Missing Files**: Clearly communicate when expected files aren't found
- **Suggest Alternatives**: If the exact file isn't found, suggest similar or related files
## Response Patterns
### For "Find files" requests:
1. Clarify the search criteria if ambiguous
2. Execute appropriate search commands
3. Present results in a clear, organized format
4. Offer to explore any findings in more detail
### For "Explore structure" requests:
1. Start with top-level directory overview
2. Identify key directories and their purposes
3. Highlight important files at each level
4. Provide a mental model of the project organization
### For "Where is X" requests:
1. Search for files matching the functionality described
2. Check common locations first based on the type of file
3. Present the most likely candidates with confidence levels
4. Verify by briefly checking file contents if needed
## Tools Usage
Prefer these approaches:
- Use `find`, `ls`, or built-in file listing for structure exploration
- Use `grep` or content search for finding files by their contents
- Use glob patterns for matching file names and extensions
- Read key files (like package.json, tsconfig.json) to understand project configuration
## Exclusions
By default, exclude from search results:
- `node_modules/` directory
- `.git/` directory
- Build output directories (`.next/`, `dist/`, `build/`)
- Cache directories (`.cache/`, `.turbo/`)
- IDE configuration (`.idea/`, `.vscode/` unless specifically requested)
Always be thorough yet efficient, providing actionable information that helps users navigate their codebase with confidence.

View File

@@ -1,5 +1,5 @@
---
description: Create a new feature with requirements and implementation plan
description: Create a new spec with requirements and implementation plan
---
# Create Feature
@@ -171,6 +171,7 @@ After creating the feature, inform the user:
### When to Use `[complex]`
Mark a task with `[complex]` when it:
- Has multiple sub-tasks that need individual tracking
- Requires significant architectural decisions or discussion
- Spans multiple files or systems

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

View File

@@ -8,7 +8,7 @@ This is a Next.js 16 boilerplate for building AI-powered applications with authe
- **Framework**: Next.js 16 with App Router, React 19, TypeScript
- **AI Integration**: Vercel AI SDK 5 + OpenRouter (access to 100+ AI models)
- **Authentication**: BetterAuth with Google OAuth
- **Authentication**: BetterAuth with Email/Password
- **Database**: PostgreSQL with Drizzle ORM
- **UI**: shadcn/ui components with Tailwind CSS 4
- **Styling**: Tailwind CSS with dark mode support (next-themes)
@@ -34,6 +34,11 @@ This is a Next.js 16 boilerplate for building AI-powered applications with authe
```
src/
├── app/ # Next.js App Router
│ ├── (auth)/ # Auth route group
│ │ ├── login/ # Login page
│ │ ├── register/ # Registration page
│ │ ├── forgot-password/ # Forgot password page
│ │ └── reset-password/ # Reset password page
│ ├── api/
│ │ ├── auth/[...all]/ # Better Auth catch-all route
│ │ ├── chat/route.ts # AI chat endpoint (OpenRouter)
@@ -45,7 +50,10 @@ src/
│ └── layout.tsx # Root layout
├── components/
│ ├── auth/ # Authentication components
│ │ ├── sign-in-button.tsx
│ │ ├── sign-in-button.tsx # Sign in form
│ │ ├── sign-up-form.tsx # Sign up form
│ │ ├── forgot-password-form.tsx
│ │ ├── reset-password-form.tsx
│ │ ├── sign-out-button.tsx
│ │ └── user-profile.tsx
│ ├── ui/ # shadcn/ui components
@@ -83,10 +91,6 @@ POSTGRES_URL=postgresql://user:password@localhost:5432/db_name
# Better Auth
BETTER_AUTH_SECRET=32-char-random-string
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# AI via OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key
OPENROUTER_MODEL=openai/gpt-5-mini # or any model from openrouter.ai/models

View File

@@ -0,0 +1,5 @@
CREATE INDEX "account_user_id_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "account_provider_account_idx" ON "account" USING btree ("provider_id","account_id");--> statement-breakpoint
CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_token_idx" ON "session" USING btree ("token");--> statement-breakpoint
CREATE INDEX "user_email_idx" ON "user" USING btree ("email");

View File

@@ -0,0 +1,410 @@
{
"id": "5737c145-8057-43dd-b149-155148e3dac7",
"prevId": "56cf4573-0efe-4f7d-908f-0c7cb0ac0739",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"account_provider_account_idx": {
"name": "account_provider_account_idx",
"columns": [
{
"expression": "provider_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_user_id_idx": {
"name": "session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"session_token_idx": {
"name": "session_token_idx",
"columns": [
{
"expression": "token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"user_email_idx": {
"name": "user_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1762409965425,
"tag": "0000_chilly_the_phantom",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1766719908715,
"tag": "0001_last_warpath",
"breakpoints": true
}
]
}

View File

@@ -5,11 +5,6 @@ POSTGRES_URL=postgresql://dev_user:dev_password@localhost:5432/postgres_dev
# Generate key using https://www.better-auth.com/docs/installation
BETTER_AUTH_SECRET=qtD4Ssa0t5jY7ewALgai97sKhAtn7Ysc
# Google OAuth (Get from Google Cloud Console)
# Redirect URI: http://localhost:3000/api/auth/callback/google
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# AI Integration via OpenRouter (Optional - for chat functionality)
# Get your API key from: https://openrouter.ai/settings/keys
# View available models at: https://openrouter.ai/models

View File

@@ -0,0 +1,35 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function ForgotPasswordPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Forgot password</CardTitle>
<CardDescription>
Enter your email address and we&apos;ll send you a reset link
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<ForgotPasswordForm />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,44 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { SignInButton } from "@/components/auth/sign-in-button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ reset?: string }>
}) {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
const { reset } = await searchParams
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>Sign in to your account</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
{reset === "success" && (
<p className="mb-4 text-sm text-green-600 dark:text-green-400">
Password reset successfully. Please sign in with your new password.
</p>
)}
<SignInButton />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { SignUpForm } from "@/components/auth/sign-up-form"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function RegisterPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Create an account</CardTitle>
<CardDescription>Get started with your new account</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<SignUpForm />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { Suspense } from "react"
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { ResetPasswordForm } from "@/components/auth/reset-password-form"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function ResetPasswordPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Reset password</CardTitle>
<CardDescription>Enter your new password below</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<Suspense fallback={<div>Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Mail, Calendar, User, Shield, ArrowLeft, Lock, Smartphone } from "lucide-react";
import { toast } from "sonner";
@@ -33,7 +33,13 @@ export default function ProfilePage() {
const [securityOpen, setSecurityOpen] = useState(false);
const [emailPrefsOpen, setEmailPrefsOpen] = useState(false);
if (isPending) {
useEffect(() => {
if (!isPending && !session) {
router.push("/");
}
}, [isPending, session, router]);
if (isPending || !session) {
return (
<div className="flex items-center justify-center min-h-screen">
<div>Loading...</div>
@@ -41,11 +47,6 @@ export default function ProfilePage() {
);
}
if (!session) {
router.push("/");
return null;
}
const user = session.user;
const createdDate = user.createdAt
? new Date(user.createdAt).toLocaleDateString("en-US", {

View File

@@ -0,0 +1,83 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { requestPasswordReset } from "@/lib/auth-client"
export function ForgotPasswordForm() {
const [email, setEmail] = useState("")
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const [isPending, setIsPending] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsPending(true)
try {
const result = await requestPasswordReset({
email,
redirectTo: "/reset-password",
})
if (result.error) {
setError(result.error.message || "Failed to send reset email")
} else {
setSuccess(true)
}
} catch {
setError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
if (success) {
return (
<div className="space-y-4 w-full max-w-sm text-center">
<p className="text-sm text-muted-foreground">
If an account exists with that email, a password reset link has been sent.
Check your terminal for the reset URL.
</p>
<Link href="/login">
<Button variant="outline" className="w-full">
Back to sign in
</Button>
</Link>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isPending}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Sending..." : "Send reset link"}
</Button>
<div className="text-center text-sm text-muted-foreground">
Remember your password?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</form>
)
}

View File

@@ -0,0 +1,107 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { resetPassword } from "@/lib/auth-client"
export function ResetPasswordForm() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get("token")
const error = searchParams.get("error")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [formError, setFormError] = useState("")
const [isPending, setIsPending] = useState(false)
if (error === "invalid_token" || !token) {
return (
<div className="space-y-4 w-full max-w-sm text-center">
<p className="text-sm text-destructive">
{error === "invalid_token"
? "This password reset link is invalid or has expired."
: "No reset token provided."}
</p>
<Link href="/forgot-password">
<Button variant="outline" className="w-full">
Request a new link
</Button>
</Link>
</div>
)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setFormError("")
if (password !== confirmPassword) {
setFormError("Passwords do not match")
return
}
if (password.length < 8) {
setFormError("Password must be at least 8 characters")
return
}
setIsPending(true)
try {
const result = await resetPassword({
newPassword: password,
token,
})
if (result.error) {
setFormError(result.error.message || "Failed to reset password")
} else {
router.push("/login?reset=success")
}
} catch {
setFormError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
placeholder="Enter new password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
{formError && (
<p className="text-sm text-destructive">{formError}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Resetting..." : "Reset password"}
</Button>
</form>
)
}

View File

@@ -1,29 +1,97 @@
"use client";
"use client"
import { Button } from "@/components/ui/button";
import { signIn, useSession } from "@/lib/auth-client";
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { signIn, useSession } from "@/lib/auth-client"
export function SignInButton() {
const { data: session, isPending } = useSession();
const { data: session, isPending: sessionPending } = useSession()
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [isPending, setIsPending] = useState(false)
if (isPending) {
return <Button disabled>Loading...</Button>;
if (sessionPending) {
return <Button disabled>Loading...</Button>
}
if (session) {
return null;
return null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsPending(true)
try {
const result = await signIn.email({
email,
password,
callbackURL: "/dashboard",
})
if (result.error) {
setError(result.error.message || "Failed to sign in")
} else {
router.push("/dashboard")
router.refresh()
}
} catch {
setError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<Button
onClick={async () => {
await signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
}}
>
Sign in
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Signing in..." : "Sign in"}
</Button>
);
<div className="text-center text-sm text-muted-foreground">
<Link href="/forgot-password" className="hover:underline">
Forgot password?
</Link>
</div>
<div className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</div>
</form>
)
}

View File

@@ -0,0 +1,121 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { signUp } from "@/lib/auth-client"
export function SignUpForm() {
const router = useRouter()
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [isPending, setIsPending] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 8) {
setError("Password must be at least 8 characters")
return
}
setIsPending(true)
try {
const result = await signUp.email({
name,
email,
password,
callbackURL: "/dashboard",
})
if (result.error) {
setError(result.error.message || "Failed to create account")
} else {
router.push("/dashboard")
router.refresh()
}
} catch {
setError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Create a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Creating account..." : "Create account"}
</Button>
<div className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</form>
)
}

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, LogOut } from "lucide-react";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,7 +14,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSession, signOut } from "@/lib/auth-client";
import { SignInButton } from "./sign-in-button";
export function UserProfile() {
const { data: session, isPending } = useSession();
@@ -25,8 +25,15 @@ export function UserProfile() {
if (!session) {
return (
<div className="flex flex-col items-center gap-4 p-6">
<SignInButton />
<div className="flex items-center gap-2">
<Link href="/login">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
<Link href="/register">
<Button size="sm">Sign up</Button>
</Link>
</div>
);
}

View File

@@ -10,4 +10,7 @@ export const {
signUp,
useSession,
getSession,
requestPasswordReset,
resetPassword,
sendVerificationEmail,
} = authClient

View File

@@ -6,10 +6,20 @@ export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
// Log password reset URL to terminal (no email integration yet)
// eslint-disable-next-line no-console
console.log(`\n${"=".repeat(60)}\nPASSWORD RESET REQUEST\nUser: ${user.email}\nReset URL: ${url}\n${"=".repeat(60)}\n`)
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
// Log verification URL to terminal (no email integration yet)
// eslint-disable-next-line no-console
console.log(`\n${"=".repeat(60)}\nEMAIL VERIFICATION\nUser: ${user.email}\nVerification URL: ${url}\n${"=".repeat(60)}\n`)
},
},
})

View File

@@ -0,0 +1,5 @@
CREATE INDEX "account_user_id_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "account_provider_account_idx" ON "account" USING btree ("provider_id","account_id");--> statement-breakpoint
CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_token_idx" ON "session" USING btree ("token");--> statement-breakpoint
CREATE INDEX "user_email_idx" ON "user" USING btree ("email");

View File

@@ -0,0 +1,410 @@
{
"id": "5737c145-8057-43dd-b149-155148e3dac7",
"prevId": "56cf4573-0efe-4f7d-908f-0c7cb0ac0739",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"account_provider_account_idx": {
"name": "account_provider_account_idx",
"columns": [
{
"expression": "provider_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_user_id_idx": {
"name": "session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"session_token_idx": {
"name": "session_token_idx",
"columns": [
{
"expression": "token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"user_email_idx": {
"name": "user_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1762409965425,
"tag": "0000_chilly_the_phantom",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1766719908715,
"tag": "0001_last_warpath",
"breakpoints": true
}
]
}

View File

@@ -5,11 +5,6 @@ POSTGRES_URL=postgresql://dev_user:dev_password@localhost:5432/postgres_dev
# Generate key using https://www.better-auth.com/docs/installation
BETTER_AUTH_SECRET=qtD4Ssa0t5jY7ewALgai97sKhAtn7Ysc
# Google OAuth (Get from Google Cloud Console)
# Redirect URI: http://localhost:3000/api/auth/callback/google
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# AI Integration via OpenRouter (Optional - for chat functionality)
# Get your API key from: https://openrouter.ai/settings/keys
# View available models at: https://openrouter.ai/models

View File

@@ -0,0 +1,35 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function ForgotPasswordPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Forgot password</CardTitle>
<CardDescription>
Enter your email address and we&apos;ll send you a reset link
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<ForgotPasswordForm />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,44 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { SignInButton } from "@/components/auth/sign-in-button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ reset?: string }>
}) {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
const { reset } = await searchParams
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>Sign in to your account</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
{reset === "success" && (
<p className="mb-4 text-sm text-green-600 dark:text-green-400">
Password reset successfully. Please sign in with your new password.
</p>
)}
<SignInButton />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { SignUpForm } from "@/components/auth/sign-up-form"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function RegisterPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Create an account</CardTitle>
<CardDescription>Get started with your new account</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<SignUpForm />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { Suspense } from "react"
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { ResetPasswordForm } from "@/components/auth/reset-password-form"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function ResetPasswordPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Reset password</CardTitle>
<CardDescription>Enter your new password below</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<Suspense fallback={<div>Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Mail, Calendar, User, Shield, ArrowLeft, Lock, Smartphone } from "lucide-react";
import { toast } from "sonner";
@@ -33,7 +33,13 @@ export default function ProfilePage() {
const [securityOpen, setSecurityOpen] = useState(false);
const [emailPrefsOpen, setEmailPrefsOpen] = useState(false);
if (isPending) {
useEffect(() => {
if (!isPending && !session) {
router.push("/");
}
}, [isPending, session, router]);
if (isPending || !session) {
return (
<div className="flex items-center justify-center min-h-screen">
<div>Loading...</div>
@@ -41,11 +47,6 @@ export default function ProfilePage() {
);
}
if (!session) {
router.push("/");
return null;
}
const user = session.user;
const createdDate = user.createdAt
? new Date(user.createdAt).toLocaleDateString("en-US", {

View File

@@ -0,0 +1,83 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { requestPasswordReset } from "@/lib/auth-client"
export function ForgotPasswordForm() {
const [email, setEmail] = useState("")
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const [isPending, setIsPending] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsPending(true)
try {
const result = await requestPasswordReset({
email,
redirectTo: "/reset-password",
})
if (result.error) {
setError(result.error.message || "Failed to send reset email")
} else {
setSuccess(true)
}
} catch {
setError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
if (success) {
return (
<div className="space-y-4 w-full max-w-sm text-center">
<p className="text-sm text-muted-foreground">
If an account exists with that email, a password reset link has been sent.
Check your terminal for the reset URL.
</p>
<Link href="/login">
<Button variant="outline" className="w-full">
Back to sign in
</Button>
</Link>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isPending}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Sending..." : "Send reset link"}
</Button>
<div className="text-center text-sm text-muted-foreground">
Remember your password?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</form>
)
}

View File

@@ -0,0 +1,107 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { resetPassword } from "@/lib/auth-client"
export function ResetPasswordForm() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get("token")
const error = searchParams.get("error")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [formError, setFormError] = useState("")
const [isPending, setIsPending] = useState(false)
if (error === "invalid_token" || !token) {
return (
<div className="space-y-4 w-full max-w-sm text-center">
<p className="text-sm text-destructive">
{error === "invalid_token"
? "This password reset link is invalid or has expired."
: "No reset token provided."}
</p>
<Link href="/forgot-password">
<Button variant="outline" className="w-full">
Request a new link
</Button>
</Link>
</div>
)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setFormError("")
if (password !== confirmPassword) {
setFormError("Passwords do not match")
return
}
if (password.length < 8) {
setFormError("Password must be at least 8 characters")
return
}
setIsPending(true)
try {
const result = await resetPassword({
newPassword: password,
token,
})
if (result.error) {
setFormError(result.error.message || "Failed to reset password")
} else {
router.push("/login?reset=success")
}
} catch {
setFormError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
placeholder="Enter new password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
{formError && (
<p className="text-sm text-destructive">{formError}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Resetting..." : "Reset password"}
</Button>
</form>
)
}

View File

@@ -1,29 +1,97 @@
"use client";
"use client"
import { Button } from "@/components/ui/button";
import { signIn, useSession } from "@/lib/auth-client";
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { signIn, useSession } from "@/lib/auth-client"
export function SignInButton() {
const { data: session, isPending } = useSession();
const { data: session, isPending: sessionPending } = useSession()
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [isPending, setIsPending] = useState(false)
if (isPending) {
return <Button disabled>Loading...</Button>;
if (sessionPending) {
return <Button disabled>Loading...</Button>
}
if (session) {
return null;
return null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsPending(true)
try {
const result = await signIn.email({
email,
password,
callbackURL: "/dashboard",
})
if (result.error) {
setError(result.error.message || "Failed to sign in")
} else {
router.push("/dashboard")
router.refresh()
}
} catch {
setError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<Button
onClick={async () => {
await signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
}}
>
Sign in
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Signing in..." : "Sign in"}
</Button>
);
<div className="text-center text-sm text-muted-foreground">
<Link href="/forgot-password" className="hover:underline">
Forgot password?
</Link>
</div>
<div className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</div>
</form>
)
}

View File

@@ -0,0 +1,121 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { signUp } from "@/lib/auth-client"
export function SignUpForm() {
const router = useRouter()
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [isPending, setIsPending] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 8) {
setError("Password must be at least 8 characters")
return
}
setIsPending(true)
try {
const result = await signUp.email({
name,
email,
password,
callbackURL: "/dashboard",
})
if (result.error) {
setError(result.error.message || "Failed to create account")
} else {
router.push("/dashboard")
router.refresh()
}
} catch {
setError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Create a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Creating account..." : "Create account"}
</Button>
<div className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</form>
)
}

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, LogOut } from "lucide-react";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,7 +14,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSession, signOut } from "@/lib/auth-client";
import { SignInButton } from "./sign-in-button";
export function UserProfile() {
const { data: session, isPending } = useSession();
@@ -25,8 +25,15 @@ export function UserProfile() {
if (!session) {
return (
<div className="flex flex-col items-center gap-4 p-6">
<SignInButton />
<div className="flex items-center gap-2">
<Link href="/login">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
<Link href="/register">
<Button size="sm">Sign up</Button>
</Link>
</div>
);
}

View File

@@ -10,4 +10,7 @@ export const {
signUp,
useSession,
getSession,
requestPasswordReset,
resetPassword,
sendVerificationEmail,
} = authClient

View File

@@ -6,10 +6,20 @@ export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
// Log password reset URL to terminal (no email integration yet)
// eslint-disable-next-line no-console
console.log(`\n${"=".repeat(60)}\nPASSWORD RESET REQUEST\nUser: ${user.email}\nReset URL: ${url}\n${"=".repeat(60)}\n`)
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
// Log verification URL to terminal (no email integration yet)
// eslint-disable-next-line no-console
console.log(`\n${"=".repeat(60)}\nEMAIL VERIFICATION\nUser: ${user.email}\nVerification URL: ${url}\n${"=".repeat(60)}\n`)
},
},
})