diff --git a/.claude/agents/file-explorer.md b/.claude/agents/file-explorer.md new file mode 100644 index 0000000..f24e0ab --- /dev/null +++ b/.claude/agents/file-explorer.md @@ -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\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\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\n\n\n\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\nSince the user wants to discover project structure, use the file-explorer agent to scan and catalog the components.\n\n\n\n\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\nSince the user needs to locate files related to a specific functionality, use the file-explorer agent to search for matching files.\n\n\n\n\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\nProactively using the file-explorer agent to understand project layout before making architectural decisions.\n\n +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. diff --git a/.claude/commands/create-feature.md b/.claude/commands/create-spec.md similarity index 98% rename from .claude/commands/create-feature.md rename to .claude/commands/create-spec.md index d3444b2..e04562a 100644 --- a/.claude/commands/create-feature.md +++ b/.claude/commands/create-spec.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 58a5a8f..ec6a14e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/create-agentic-app/package-lock.json b/create-agentic-app/package-lock.json index eb23688..1e58857 100644 --- a/create-agentic-app/package-lock.json +++ b/create-agentic-app/package-lock.json @@ -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", diff --git a/create-agentic-app/package.json b/create-agentic-app/package.json index c927fdd..b959673 100644 --- a/create-agentic-app/package.json +++ b/create-agentic-app/package.json @@ -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": { diff --git a/create-agentic-app/template/.claude/agents/file-explorer.md b/create-agentic-app/template/.claude/agents/file-explorer.md new file mode 100644 index 0000000..f24e0ab --- /dev/null +++ b/create-agentic-app/template/.claude/agents/file-explorer.md @@ -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\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\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\n\n\n\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\nSince the user wants to discover project structure, use the file-explorer agent to scan and catalog the components.\n\n\n\n\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\nSince the user needs to locate files related to a specific functionality, use the file-explorer agent to search for matching files.\n\n\n\n\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\nProactively using the file-explorer agent to understand project layout before making architectural decisions.\n\n +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. diff --git a/create-agentic-app/template/.claude/commands/create-feature.md b/create-agentic-app/template/.claude/commands/create-spec.md similarity index 98% rename from create-agentic-app/template/.claude/commands/create-feature.md rename to create-agentic-app/template/.claude/commands/create-spec.md index d3444b2..e04562a 100644 --- a/create-agentic-app/template/.claude/commands/create-feature.md +++ b/create-agentic-app/template/.claude/commands/create-spec.md @@ -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 diff --git a/create-agentic-app/template/.playwright-mcp/dark-mode-homepage.png b/create-agentic-app/template/.playwright-mcp/dark-mode-homepage.png deleted file mode 100644 index a696de7..0000000 Binary files a/create-agentic-app/template/.playwright-mcp/dark-mode-homepage.png and /dev/null differ diff --git a/create-agentic-app/template/.playwright-mcp/light-mode-homepage.png b/create-agentic-app/template/.playwright-mcp/light-mode-homepage.png deleted file mode 100644 index 86f619f..0000000 Binary files a/create-agentic-app/template/.playwright-mcp/light-mode-homepage.png and /dev/null differ diff --git a/create-agentic-app/template/.playwright-mcp/page-2025-11-07T05-24-10-069Z.png b/create-agentic-app/template/.playwright-mcp/page-2025-11-07T05-24-10-069Z.png deleted file mode 100644 index 70f86c2..0000000 Binary files a/create-agentic-app/template/.playwright-mcp/page-2025-11-07T05-24-10-069Z.png and /dev/null differ diff --git a/create-agentic-app/template/CLAUDE.md b/create-agentic-app/template/CLAUDE.md index 58a5a8f..ec6a14e 100644 --- a/create-agentic-app/template/CLAUDE.md +++ b/create-agentic-app/template/CLAUDE.md @@ -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 diff --git a/create-agentic-app/template/drizzle/0001_last_warpath.sql b/create-agentic-app/template/drizzle/0001_last_warpath.sql new file mode 100644 index 0000000..494dd8a --- /dev/null +++ b/create-agentic-app/template/drizzle/0001_last_warpath.sql @@ -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"); \ No newline at end of file diff --git a/create-agentic-app/template/drizzle/meta/0001_snapshot.json b/create-agentic-app/template/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..9d6fc79 --- /dev/null +++ b/create-agentic-app/template/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/create-agentic-app/template/drizzle/meta/_journal.json b/create-agentic-app/template/drizzle/meta/_journal.json index 63d838e..81aebd8 100644 --- a/create-agentic-app/template/drizzle/meta/_journal.json +++ b/create-agentic-app/template/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/create-agentic-app/template/env.example b/create-agentic-app/template/env.example index 1854928..a0b471a 100644 --- a/create-agentic-app/template/env.example +++ b/create-agentic-app/template/env.example @@ -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 diff --git a/create-agentic-app/template/src/app/(auth)/forgot-password/page.tsx b/create-agentic-app/template/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..3b95371 --- /dev/null +++ b/create-agentic-app/template/src/app/(auth)/forgot-password/page.tsx @@ -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 ( +
+ + + Forgot password + + Enter your email address and we'll send you a reset link + + + + + + +
+ ) +} diff --git a/create-agentic-app/template/src/app/(auth)/layout.tsx b/create-agentic-app/template/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..48eacd2 --- /dev/null +++ b/create-agentic-app/template/src/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode +}) { + return <>{children} +} diff --git a/create-agentic-app/template/src/app/(auth)/login/page.tsx b/create-agentic-app/template/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..003503b --- /dev/null +++ b/create-agentic-app/template/src/app/(auth)/login/page.tsx @@ -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 ( +
+ + + Welcome back + Sign in to your account + + + {reset === "success" && ( +

+ Password reset successfully. Please sign in with your new password. +

+ )} + +
+
+
+ ) +} diff --git a/create-agentic-app/template/src/app/(auth)/register/page.tsx b/create-agentic-app/template/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..449ecd7 --- /dev/null +++ b/create-agentic-app/template/src/app/(auth)/register/page.tsx @@ -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 ( +
+ + + Create an account + Get started with your new account + + + + + +
+ ) +} diff --git a/create-agentic-app/template/src/app/(auth)/reset-password/page.tsx b/create-agentic-app/template/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..aa3b888 --- /dev/null +++ b/create-agentic-app/template/src/app/(auth)/reset-password/page.tsx @@ -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 ( +
+ + + Reset password + Enter your new password below + + + Loading...
}> + + + + + + ) +} diff --git a/create-agentic-app/template/src/app/profile/page.tsx b/create-agentic-app/template/src/app/profile/page.tsx index 6a7870e..a33bbbb 100644 --- a/create-agentic-app/template/src/app/profile/page.tsx +++ b/create-agentic-app/template/src/app/profile/page.tsx @@ -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 (
Loading...
@@ -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", { diff --git a/create-agentic-app/template/src/components/auth/forgot-password-form.tsx b/create-agentic-app/template/src/components/auth/forgot-password-form.tsx new file mode 100644 index 0000000..b8f1dd8 --- /dev/null +++ b/create-agentic-app/template/src/components/auth/forgot-password-form.tsx @@ -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 ( +
+

+ If an account exists with that email, a password reset link has been sent. + Check your terminal for the reset URL. +

+ + + +
+ ) + } + + return ( +
+
+ + setEmail(e.target.value)} + required + disabled={isPending} + /> +
+ {error && ( +

{error}

+ )} + +
+ Remember your password?{" "} + + Sign in + +
+
+ ) +} diff --git a/create-agentic-app/template/src/components/auth/reset-password-form.tsx b/create-agentic-app/template/src/components/auth/reset-password-form.tsx new file mode 100644 index 0000000..ff66d3a --- /dev/null +++ b/create-agentic-app/template/src/components/auth/reset-password-form.tsx @@ -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 ( +
+

+ {error === "invalid_token" + ? "This password reset link is invalid or has expired." + : "No reset token provided."} +

+ + + +
+ ) + } + + 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 ( +
+
+ + setPassword(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + disabled={isPending} + /> +
+ {formError && ( +

{formError}

+ )} + +
+ ) +} diff --git a/create-agentic-app/template/src/components/auth/sign-in-button.tsx b/create-agentic-app/template/src/components/auth/sign-in-button.tsx index 7bed448..8899f0d 100644 --- a/create-agentic-app/template/src/components/auth/sign-in-button.tsx +++ b/create-agentic-app/template/src/components/auth/sign-in-button.tsx @@ -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 ; + if (sessionPending) { + return } 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 ( - - ); +
+
+ + setEmail(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setPassword(e.target.value)} + required + disabled={isPending} + /> +
+ {error && ( +

{error}

+ )} + +
+ + Forgot password? + +
+
+ Don't have an account?{" "} + + Sign up + +
+
+ ) } diff --git a/create-agentic-app/template/src/components/auth/sign-up-form.tsx b/create-agentic-app/template/src/components/auth/sign-up-form.tsx new file mode 100644 index 0000000..92038aa --- /dev/null +++ b/create-agentic-app/template/src/components/auth/sign-up-form.tsx @@ -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 ( +
+
+ + setName(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setEmail(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setPassword(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + disabled={isPending} + /> +
+ {error && ( +

{error}

+ )} + +
+ Already have an account?{" "} + + Sign in + +
+
+ ) +} diff --git a/create-agentic-app/template/src/components/auth/user-profile.tsx b/create-agentic-app/template/src/components/auth/user-profile.tsx index 34f0ec5..09b44ee 100644 --- a/create-agentic-app/template/src/components/auth/user-profile.tsx +++ b/create-agentic-app/template/src/components/auth/user-profile.tsx @@ -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 ( -
- +
+ + + + + +
); } diff --git a/create-agentic-app/template/src/lib/auth-client.ts b/create-agentic-app/template/src/lib/auth-client.ts index 951c086..3afa911 100644 --- a/create-agentic-app/template/src/lib/auth-client.ts +++ b/create-agentic-app/template/src/lib/auth-client.ts @@ -10,4 +10,7 @@ export const { signUp, useSession, getSession, + requestPasswordReset, + resetPassword, + sendVerificationEmail, } = authClient \ No newline at end of file diff --git a/create-agentic-app/template/src/lib/auth.ts b/create-agentic-app/template/src/lib/auth.ts index 731a7ed..1deb11f 100644 --- a/create-agentic-app/template/src/lib/auth.ts +++ b/create-agentic-app/template/src/lib/auth.ts @@ -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`) }, }, }) \ No newline at end of file diff --git a/drizzle/0001_last_warpath.sql b/drizzle/0001_last_warpath.sql new file mode 100644 index 0000000..494dd8a --- /dev/null +++ b/drizzle/0001_last_warpath.sql @@ -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"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..9d6fc79 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 63d838e..81aebd8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/env.example b/env.example index 1854928..a0b471a 100644 --- a/env.example +++ b/env.example @@ -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 diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..3b95371 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -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 ( +
+ + + Forgot password + + Enter your email address and we'll send you a reset link + + + + + + +
+ ) +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..48eacd2 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode +}) { + return <>{children} +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..003503b --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -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 ( +
+ + + Welcome back + Sign in to your account + + + {reset === "success" && ( +

+ Password reset successfully. Please sign in with your new password. +

+ )} + +
+
+
+ ) +} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..449ecd7 --- /dev/null +++ b/src/app/(auth)/register/page.tsx @@ -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 ( +
+ + + Create an account + Get started with your new account + + + + + +
+ ) +} diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..aa3b888 --- /dev/null +++ b/src/app/(auth)/reset-password/page.tsx @@ -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 ( +
+ + + Reset password + Enter your new password below + + + Loading...
}> + + + + +
+ ) +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 6a7870e..a33bbbb 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -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 (
Loading...
@@ -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", { diff --git a/src/components/auth/forgot-password-form.tsx b/src/components/auth/forgot-password-form.tsx new file mode 100644 index 0000000..b8f1dd8 --- /dev/null +++ b/src/components/auth/forgot-password-form.tsx @@ -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 ( +
+

+ If an account exists with that email, a password reset link has been sent. + Check your terminal for the reset URL. +

+ + + +
+ ) + } + + return ( +
+
+ + setEmail(e.target.value)} + required + disabled={isPending} + /> +
+ {error && ( +

{error}

+ )} + +
+ Remember your password?{" "} + + Sign in + +
+
+ ) +} diff --git a/src/components/auth/reset-password-form.tsx b/src/components/auth/reset-password-form.tsx new file mode 100644 index 0000000..ff66d3a --- /dev/null +++ b/src/components/auth/reset-password-form.tsx @@ -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 ( +
+

+ {error === "invalid_token" + ? "This password reset link is invalid or has expired." + : "No reset token provided."} +

+ + + +
+ ) + } + + 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 ( +
+
+ + setPassword(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + disabled={isPending} + /> +
+ {formError && ( +

{formError}

+ )} + +
+ ) +} diff --git a/src/components/auth/sign-in-button.tsx b/src/components/auth/sign-in-button.tsx index 7bed448..8899f0d 100644 --- a/src/components/auth/sign-in-button.tsx +++ b/src/components/auth/sign-in-button.tsx @@ -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 ; + if (sessionPending) { + return } 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 ( - - ); +
+
+ + setEmail(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setPassword(e.target.value)} + required + disabled={isPending} + /> +
+ {error && ( +

{error}

+ )} + +
+ + Forgot password? + +
+
+ Don't have an account?{" "} + + Sign up + +
+
+ ) } diff --git a/src/components/auth/sign-up-form.tsx b/src/components/auth/sign-up-form.tsx new file mode 100644 index 0000000..92038aa --- /dev/null +++ b/src/components/auth/sign-up-form.tsx @@ -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 ( +
+
+ + setName(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setEmail(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setPassword(e.target.value)} + required + disabled={isPending} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + disabled={isPending} + /> +
+ {error && ( +

{error}

+ )} + +
+ Already have an account?{" "} + + Sign in + +
+
+ ) +} diff --git a/src/components/auth/user-profile.tsx b/src/components/auth/user-profile.tsx index 34f0ec5..09b44ee 100644 --- a/src/components/auth/user-profile.tsx +++ b/src/components/auth/user-profile.tsx @@ -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 ( -
- +
+ + + + + +
); } diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 951c086..3afa911 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -10,4 +10,7 @@ export const { signUp, useSession, getSession, + requestPasswordReset, + resetPassword, + sendVerificationEmail, } = authClient \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 731a7ed..1deb11f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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`) }, }, }) \ No newline at end of file