├── .cursor └── rules │ ├── auth-workflows.mdc │ ├── component-architecture.mdc │ ├── data-models.mdc │ └── security-algorithms.mdc ├── .cursorignore ├── .env.example ├── .giga └── specifications.json ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bun.lock ├── components.json ├── docs ├── auth-flow.md ├── cleanup-setup.md ├── flow-questions.md ├── plan.md ├── project-overview.md ├── roadmap.md ├── supabase-email-templates.md ├── supabase-snippets.md └── verification-example.tsx ├── emails ├── components │ └── header.tsx └── templates │ ├── data-export-ready.tsx │ ├── data-export-requested.tsx │ ├── device-verification.tsx │ ├── email-alert.tsx │ └── email-verification.tsx ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── scripts └── reset-project.ts ├── src ├── app │ ├── account │ │ ├── data │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── security │ │ │ └── page.tsx │ ├── api │ │ └── auth │ │ │ ├── 2fa │ │ │ ├── disable │ │ │ │ └── route.ts │ │ │ └── enroll │ │ │ │ └── route.ts │ │ │ ├── callback │ │ │ └── route.ts │ │ │ ├── change-email │ │ │ └── route.ts │ │ │ ├── change-password │ │ │ └── route.ts │ │ │ ├── confirm │ │ │ └── route.ts │ │ │ ├── data-exports │ │ │ ├── [id] │ │ │ │ ├── download │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ │ ├── device-sessions │ │ │ ├── current │ │ │ │ └── route.ts │ │ │ ├── geolocation │ │ │ │ └── route.ts │ │ │ ├── revoke │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── trusted │ │ │ │ └── route.ts │ │ │ ├── email │ │ │ ├── check │ │ │ │ └── route.ts │ │ │ ├── login │ │ │ │ └── route.ts │ │ │ ├── resend-confirmation │ │ │ │ └── route.ts │ │ │ ├── send-verification │ │ │ │ └── route.ts │ │ │ └── signup │ │ │ │ └── route.ts │ │ │ ├── forgot-password │ │ │ └── route.ts │ │ │ ├── github │ │ │ └── signin │ │ │ │ └── route.ts │ │ │ ├── google │ │ │ └── signin │ │ │ │ └── route.ts │ │ │ ├── logout │ │ │ └── route.ts │ │ │ ├── post-auth │ │ │ └── route.ts │ │ │ ├── reset-password │ │ │ └── route.ts │ │ │ ├── send-email-alert │ │ │ └── route.ts │ │ │ ├── social │ │ │ ├── connect │ │ │ │ └── route.ts │ │ │ └── disconnect │ │ │ │ └── route.ts │ │ │ ├── user │ │ │ ├── avatar │ │ │ │ └── update │ │ │ │ │ └── route.ts │ │ │ ├── delete │ │ │ │ └── route.ts │ │ │ ├── events │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── update │ │ │ │ └── route.ts │ │ │ ├── verify-device │ │ │ ├── route.ts │ │ │ └── send-code │ │ │ │ └── route.ts │ │ │ └── verify │ │ │ └── route.ts │ ├── auth │ │ ├── error │ │ │ └── page.tsx │ │ ├── forgot-password │ │ │ └── page.tsx │ │ ├── login-help │ │ │ └── page.tsx │ │ ├── login │ │ │ └── page.tsx │ │ ├── reset-password │ │ │ └── page.tsx │ │ ├── signup │ │ │ └── page.tsx │ │ └── verify-device │ │ │ └── page.tsx │ ├── dashboard │ │ └── page.tsx │ ├── favicon.ico │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components │ ├── 2fa-methods.tsx │ ├── 2fa-setup-dialog.tsx │ ├── auth-confirm.tsx │ ├── auth-form.tsx │ ├── back-button.tsx │ ├── data-export.tsx │ ├── delete-account.tsx │ ├── device-sessions-list.tsx │ ├── event-log.tsx │ ├── header.tsx │ ├── revoke-all-devices.tsx │ ├── social-providers.tsx │ ├── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── phone-input.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx │ ├── url-error-handler.tsx │ ├── user-dropdown.tsx │ ├── user-provider.tsx │ └── verify-form.tsx ├── config │ └── auth.ts ├── hooks │ ├── use-account-events.ts │ ├── use-auth.ts │ ├── use-data-exports.ts │ ├── use-device-sessions.ts │ ├── use-mobile.tsx │ └── use-toast.ts ├── lib │ └── utils.ts ├── middleware.ts ├── trigger │ └── user-data-exports.ts ├── types │ ├── api.ts │ └── auth.ts ├── utils │ ├── account-events │ │ └── server.ts │ ├── api.ts │ ├── auth │ │ ├── device-sessions │ │ │ ├── index.ts │ │ │ └── server.ts │ │ ├── index.ts │ │ ├── recovery-token.ts │ │ └── verification-codes.ts │ ├── data-export │ │ ├── index.ts │ │ └── server.ts │ ├── email-alerts.ts │ ├── rate-limit.ts │ └── supabase │ │ ├── client.ts │ │ ├── middleware.ts │ │ └── server.ts └── validation │ └── auth-validation.ts ├── tailwind.config.ts ├── trigger.config.ts └── tsconfig.json /.cursor/rules/auth-workflows.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # auth-workflows 7 | 8 | ### Two-Factor Authentication (2FA) Implementation 9 | - MUST: Implement 2FA using authenticator apps and SMS with the following flow: 10 | 1. Generate verification codes using `src/utils/auth/verification-codes.ts` 11 | 2. Validate codes against hashes 12 | - AVOID: 13 | - Storing raw verification codes 14 | - Using email as primary 2FA method 15 | - Implementing custom code generation 16 | - WHY: Ensures secure time-based verification while following industry standards 17 | - EXAMPLE: `src/utils/auth/verification-codes.ts` 18 | 19 | ### Device Trust Calculation 20 | - MUST: Calculate device trust scores using: 21 | - Device name match (30 points) 22 | - Browser match (20 points) 23 | - OS match (20 points) 24 | - IP range match (15 points) 25 | - AVOID: 26 | - Storing raw device identifiers 27 | - Using location as primary trust factor 28 | - Skipping verification for partially trusted devices 29 | - WHY: Provides risk-based authentication while protecting user privacy 30 | - EXAMPLE: `src/utils/auth/index.ts` 31 | 32 | ### Session Management 33 | - MUST: Implement sessions with: 34 | 1. Device fingerprinting via `src/utils/auth/device-sessions/server.ts` 35 | 2. Session revocation requiring 2FA via `src/components/device-sessions-list.tsx` 36 | 3. Location tracking for non-local IPs 37 | - AVOID: 38 | - Storing sessions without device context 39 | - Auto-extending expired sessions 40 | - Using client-side session storage 41 | - WHY: Enables secure multi-device access while maintaining user control 42 | - EXAMPLE: `src/hooks/use-device-sessions.ts` 43 | 44 | ### Account Security Events 45 | - MUST: Log security events with: 46 | - Device information 47 | - Event category (success/warning/error) 48 | - Verification method used 49 | - IP address and location 50 | - AVOID: 51 | - Logging sensitive data 52 | - Missing critical security events 53 | - Delayed event logging 54 | - WHY: Provides audit trail and security monitoring 55 | - EXAMPLE: `src/utils/account-events/server.ts` 56 | 57 | ### Email Alert System 58 | - MUST: Send alerts for: 59 | - New device logins 60 | - 2FA changes 61 | - Password changes 62 | - Email changes 63 | - Account deletion 64 | - AVOID: 65 | - Sending alerts without device context 66 | - Using generic templates 67 | - Blocking main operations on alert failure 68 | - WHY: Keeps users informed of security-relevant account changes 69 | - EXAMPLE: `src/utils/email-alerts.ts` 70 | 71 | ### Rate Limiting 72 | - MUST: Implement tiered rate limits: 73 | - Auth operations: 10/10s 74 | - SMS operations: User+IP based limits 75 | - Data exports: 3/day 76 | - AVOID: 77 | - Global rate limits 78 | - Client-side rate limiting 79 | - Sharing limits across tenants 80 | - WHY: Prevents abuse while allowing legitimate high-volume usage 81 | - EXAMPLE: `src/utils/rate-limit.ts` 82 | 83 | $END$ -------------------------------------------------------------------------------- /.cursor/rules/component-architecture.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # component-architecture 7 | 8 | ### Component Structure & Dependencies 9 | - MUST: Structure form components hierarchically with `AuthForm` as parent and specialized validation components as children 10 | - AVOID: Direct API calls in form components - use hooks for data fetching/mutations 11 | - WHY: Separates form UI from business logic, enables reuse of validation patterns 12 | - EXAMPLE: `src/components/auth-form.tsx` coordinates child components like `2fa-methods.tsx` 13 | 14 | ### Authentication Flow Components 15 | - MUST: Use hook-based state management for auth flows (login, signup, 2FA) 16 | - AVOID: Mixing auth state across components, keeping auth logic in UI components 17 | - WHY: Centralizes auth state management, simplifies testing 18 | - EXAMPLE: `src/components/user-provider.tsx` handles global auth state 19 | 20 | ### Form Validation 21 | - MUST: Implement field-level validation using `useFormField` hook pattern 22 | - AVOID: Custom validation logic inside form components 23 | - WHY: Consistent validation behaviors across forms 24 | - EXAMPLE: `src/components/ui/form.tsx` FormFieldContext pattern 25 | 26 | ### Toast Notifications 27 | - MUST: Use centralized toast system via `useToast` hook for all user feedback 28 | - AVOID: Multiple toast implementations or direct toast calls 29 | - WHY: Consistent notification styling and behavior 30 | - EXAMPLE: `src/components/ui/toast.tsx` provides toast primitives 31 | 32 | ### Data Loading States 33 | - MUST: Implement skeleton loading components during data fetches 34 | - AVOID: Showing empty states or spinners 35 | - WHY: Provides visual continuity during loading 36 | - EXAMPLE: `src/components/ui/skeleton.tsx` skeleton components 37 | 38 | ### Device Session Management 39 | - MUST: Use `DeviceSessionsList` component for managing active sessions 40 | - AVOID: Direct session manipulation outside session components 41 | - WHY: Centralizes session management UI/logic 42 | - EXAMPLE: `src/components/device-sessions-list.tsx` 43 | 44 | ### Two-Factor Authentication 45 | - MUST: Use `2FASetupDialog` for all 2FA enrollment flows 46 | - AVOID: Custom 2FA setup implementations 47 | - WHY: Consistent 2FA setup experience 48 | - EXAMPLE: `src/components/2fa-setup-dialog.tsx` 49 | 50 | ### Export Data Workflow 51 | - MUST: Use `DataExport` component for handling user data exports 52 | - AVOID: Custom export implementations 53 | - WHY: Standardizes export process and UI 54 | - EXAMPLE: `src/components/data-export.tsx` 55 | 56 | ### Account Management 57 | - MUST: Use `DeleteAccount` component for account deletion flows 58 | - AVOID: Custom deletion implementations 59 | - WHY: Ensures proper verification and cleanup 60 | - EXAMPLE: `src/components/delete-account.tsx` 61 | 62 | ### Social Provider Integration 63 | - MUST: Use `SocialProviders` component for OAuth integration 64 | - AVOID: Direct OAuth provider implementation 65 | - WHY: Centralizes social auth handling 66 | - EXAMPLE: `src/components/social-providers.tsx` 67 | 68 | $END$ -------------------------------------------------------------------------------- /.cursor/rules/data-models.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # data-models 7 | 8 | ### Core Data Models 9 | 10 | #### User Account Model 11 | - MUST: Implement user accounts with: 12 | - Unique identifier (UUID) 13 | - Email address (verified status) 14 | - Password hash (bcrypt) 15 | - Account status (active/disabled) 16 | - Creation timestamp 17 | - Last login timestamp 18 | - AVOID: Storing sensitive data in plain text 19 | - WHY: Core identity model for authentication and access control 20 | - EXAMPLE: `src/types/auth.ts` 21 | 22 | #### Device Sessions 23 | - MUST: Track device sessions with: 24 | - Session ID 25 | - User ID (foreign key) 26 | - Device info (browser, OS, IP) 27 | - Trust score (0-100) 28 | - Last active timestamp 29 | - Verification status 30 | - AVOID: Storing raw IP addresses without hashing 31 | - WHY: Required for security monitoring and session management 32 | - EXAMPLE: `src/utils/auth/device-sessions/server.ts` 33 | 34 | #### Account Events 35 | - MUST: Log security events with: 36 | - Event ID 37 | - User ID 38 | - Event type (enum) 39 | - Metadata (JSON) 40 | - Device session ID (foreign key) 41 | - Timestamp 42 | - AVOID: Including PII in metadata 43 | - WHY: Audit trail for security and compliance 44 | - EXAMPLE: `src/utils/account-events/server.ts` 45 | 46 | ### Authentication Methods 47 | 48 | #### Two-Factor Authentication 49 | - MUST: Store 2FA configuration: 50 | - Method type (authenticator/SMS) 51 | - Backup codes (hashed) 52 | - Phone number (E.164 format) 53 | - Verification status 54 | - Setup timestamp 55 | - AVOID: Storing TOTP secrets in plain text 56 | - WHY: Required for multi-factor security 57 | - EXAMPLE: `src/types/auth.ts` 58 | 59 | #### Social Providers 60 | - MUST: Track OAuth connections: 61 | - Provider type (Google/GitHub) 62 | - Provider user ID 63 | - Access tokens (encrypted) 64 | - Connection status 65 | - Last sync timestamp 66 | - AVOID: Storing refresh tokens in database 67 | - WHY: Enables social login integration 68 | - EXAMPLE: `src/utils/auth/index.ts` 69 | 70 | ### Data Export Models 71 | 72 | #### Export Requests 73 | - MUST: Track export jobs with: 74 | - Request ID 75 | - User ID 76 | - Status (pending/processing/complete) 77 | - File path 78 | - Created timestamp 79 | - Expiry timestamp 80 | - AVOID: Storing exported data in database 81 | - WHY: Manages user data export workflow 82 | - EXAMPLE: `src/utils/data-export/server.ts` 83 | 84 | ### Relationships and Constraints 85 | - MUST: Implement cascading deletes for: 86 | - User → Device Sessions 87 | - User → Account Events 88 | - User → 2FA Methods 89 | - User → Social Providers 90 | - MUST: Enforce unique constraints on: 91 | - User email addresses 92 | - Device session IDs 93 | - Export request IDs 94 | - WHY: Maintains data integrity and prevents orphaned records 95 | 96 | $END$ -------------------------------------------------------------------------------- /.cursor/rules/security-algorithms.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # security-algorithms 7 | 8 | ### Device Trust Score Calculation 9 | - MUST: Implement trust scoring using factors with exact weights: 10 | - Device name match: 30 points 11 | - Browser match: 20 points 12 | - OS match: 20 points 13 | - IP range match: 15 points 14 | - AVOID: Using custom weights or additional factors 15 | - WHY: Consistent evaluation of device trustworthiness across application 16 | - EXAMPLE: `src/utils/auth/index.ts` 17 | ```ts 18 | const calculateTrustScore = (device: DeviceInfo) => { 19 | let score = 0; 20 | if (device.name === storedDevice.name) score += 30; 21 | if (device.browser === storedDevice.browser) score += 20; 22 | //...etc 23 | } 24 | ``` 25 | 26 | ### Rate Limiting Tiers 27 | - MUST: Implement the following rate limit tiers: 28 | - Auth operations: 10 requests/10 seconds 29 | - Authenticated operations: 100 requests/minute 30 | - General protection: 1000 requests/minute 31 | - SMS operations: IP + user-based limits 32 | - Data exports: 3 requests/day 33 | - AVOID: Custom rate limit values or alternative implementations 34 | - WHY: Protects against abuse while ensuring legitimate access 35 | - EXAMPLE: `src/utils/rate-limit.ts` 36 | 37 | ### Verification Code Generation 38 | - MUST: Generate verification codes using: 39 | - Authenticator: 6-digit numeric codes 40 | - SMS: 6-digit numeric codes 41 | - Email: Custom length alphanumeric codes 42 | - Backup codes: Word-based or alphanumeric format 43 | - AVOID: Custom code formats or lengths 44 | - WHY: Ensures compatibility with standard authenticator apps and SMS 45 | - EXAMPLE: `src/utils/auth/verification-codes.ts` 46 | 47 | ### Recovery Token Generation 48 | - MUST: Generate recovery tokens using: 49 | - 32 bytes of random data 50 | - URL-safe base64 encoding 51 | - 1 hour expiration 52 | - AVOID: Custom token formats or expiration times 53 | - WHY: Industry standard approach for secure recovery links 54 | - EXAMPLE: `src/utils/auth/recovery-token.ts` 55 | 56 | ### Data Export Security 57 | - MUST: Implement the following controls: 58 | - One-time use download tokens 59 | - 24-hour token expiration 60 | - Automatic file cleanup after download 61 | - Rate limiting of 3 requests per day 62 | - AVOID: Permanent download links or extended token validity 63 | - WHY: Prevents unauthorized access to exported user data 64 | - EXAMPLE: `src/utils/data-export/server.ts` 65 | 66 | $END$ -------------------------------------------------------------------------------- /.cursorignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for committing if needed) 33 | .env* 34 | !.env.example 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | CONTRIBUTING.dev.md 44 | scripts/decode-jwt.py 45 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key 3 | SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key 4 | NEXT_PUBLIC_SITE_URL=http://localhost:3000 5 | 6 | # Optional features 7 | 8 | # Require re-login after password reset (generate using `openssl rand -hex 32` in the terminal) 9 | # RECOVERY_TOKEN_SECRET=your-recovery-token-secret 10 | 11 | # Email (recommended for production) 12 | # RESEND_API_KEY=your-resend-api-key 13 | # RESEND_FROM_EMAIL="Auth " 14 | 15 | # API rate limiting (recommended for production) 16 | # UPSTASH_REDIS_REST_URL=your-upstash-redis-rest-url 17 | # UPSTASH_REDIS_REST_TOKEN=your-upstash-redis-rest-token 18 | 19 | # Trigger.dev (user data exports) 20 | # TRIGGER_API_KEY=your-trigger-api-key 21 | 22 | # Twilio (for SMS 2FA) 23 | # TWILIO_ACCOUNT_SID=your-account-sid 24 | # TWILIO_AUTH_TOKEN=your-auth-token 25 | # TWILIO_PHONE_NUMBER=your-phone-number 26 | -------------------------------------------------------------------------------- /.giga/specifications.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fileName": "main-overview.mdc", 4 | "description": "High-level system architecture documentation covering the core auth system, component interactions, and data flow patterns" 5 | }, 6 | { 7 | "fileName": "auth-workflows.mdc", 8 | "description": "Detailed documentation of authentication flows including 2FA implementation, verification methods, device trust calculation, and session management algorithms" 9 | }, 10 | { 11 | "fileName": "data-models.mdc", 12 | "description": "Documentation of core data models including user accounts, device sessions, account events, auth methods, and their relationships" 13 | }, 14 | { 15 | "fileName": "security-algorithms.mdc", 16 | "description": "Documentation of security-related algorithms including device trust scoring, rate limiting implementation, verification code generation/validation, and data export processing" 17 | }, 18 | { 19 | "fileName": "component-architecture.mdc", 20 | "description": "Documentation of the UI component architecture focusing on form handling, validation patterns, and state management across auth-related components" 21 | } 22 | ] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for committing if needed) 33 | .env* 34 | !.env.example 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | # cursor 44 | .cursorrules 45 | .cursorignore 46 | 47 | # I'm adding this because I used the local history for the first time in this project 48 | # Now I can't stop the IDE from saving a million files to this folder 49 | # It probably got trust issues with me, like I'm gonna lose my work again 50 | # You can delete this if you don't got same problem (unlikely you do) 51 | .history 52 | 53 | CONTRIBUTING.dev.md 54 | scripts/decode-jwt.py 55 | 56 | .trigger 57 | .cursor/mcp.json -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Auth Starterpack 2 | 3 | First off, you're awesome for wanting to contribute! 🎉 4 | 5 | ### Found a bug? 6 | 7 | Here's how to report it: 8 | 1. Check if someone already reported it in [Issues](https://github.com/mazeincoding/Auth-Starter) 9 | 2. If not, create a new one with: 10 | - How to make the bug happen 11 | - What should happen 12 | - What actually happens 13 | - Screenshots if you can 14 | - Your browser (Chrome, Safari, etc) 15 | 16 | ## Want to contribute code? 17 | 18 | 1. Fork the repo 19 | 2. Create your feature branch: 20 | ```bash 21 | git checkout -b feature/awesome-feature 22 | ``` 23 | 3. Make your changes 24 | 4. Test everything works 25 | 5. Push to your fork 26 | 6. Open a Pull Request 27 | 28 | ## Pull Request Tips 29 | 30 | Keep these in mind: 31 | - Focus on one thing at a time 32 | - Follow the existing style 33 | - Update docs if needed 34 | - Explain your changes clearly 35 | 36 | ## Need help? 37 | 38 | You can either: 39 | - DM me directly on [X/Twitter](https://x.com/mazewinther1) 40 | - Open a GitHub issue 41 | - Email me at hi@mazewinther.com 42 | 43 | ## What this project is about 44 | 45 | Think of this as the authentication foundation that developers can actually build on. Just like how Shadcn UI revolutionized UI components by making them customizable, we're doing the same for auth. 46 | 47 | We focus purely on authentication. That means: 48 | - ✅ Email verification 49 | - ✅ Device management 50 | - ✅ Security alerts 51 | - ✅ Basic user settings (email, password, name) 52 | - ❌ In-app notifications (not auth-related) 53 | - ❌ User profiles (usernames, bios, profile pages, etc) 54 | 55 | This keeps the project focused while giving developers a solid foundation they can extend. 56 | 57 | We also like simple solutions. That way, the project is easier to pick up on. 58 | 59 | ## Be awesome to each other 60 | 61 | A few ground rules: 62 | - Be kind and helpful 63 | - Focus on solutions, not blame 64 | - Welcome newcomers 65 | - Share knowledge 66 | 67 | Let's make authentication great again! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mazeway 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /docs/auth-flow.md: -------------------------------------------------------------------------------- 1 | # Authentication Flow 2 | 3 | ## 1. User Initiates Login 4 | - Via Email/Password 5 | - Via Google OAuth 6 | - Both redirect to /api/auth/callback 7 | 8 | ## 2. Callback Route (/api/auth/callback) 9 | 1. Gets user info from Supabase 10 | 2. Creates/updates user in our DB 11 | 3. Detects device info: 12 | - Browser details 13 | - OS info 14 | - IP address 15 | 16 | ## 3. Device Session Creation 17 | 1. Create/find device record 18 | 2. Compare with user's existing devices 19 | 3. Calculate confidence score: 20 | - High (70+): Probably same device 21 | - Medium (40-69): Similar device 22 | - Low (<40): New/unknown device 23 | 24 | ## 4. Set Access Level 25 | - Full: Known device, high confidence 26 | - Verified: Similar device, medium confidence 27 | - Restricted: New device, low confidence 28 | 29 | ## 5. User Notification 30 | - Email alert for all new logins 31 | - Redirect to app 32 | 33 | ## Security Notes 34 | - All logins get notifications 35 | - Google auth gets higher trust 36 | - Device info stored for future comparison -------------------------------------------------------------------------------- /docs/cleanup-setup.md: -------------------------------------------------------------------------------- 1 | # Setting up automatic database cleanup 2 | 3 | ## 1. Enable Database Extensions 4 | 5 | 1. Go to [Database Extensions](https://supabase.com/dashboard/project/_/database/extensions) 6 | 2. Enable the `pg_cron` extension it's not already 7 | 8 | ## 2. Run SQL queries 9 | 10 | 1. Go to [SQL Editor](https://supabase.com/dashboard/project/_/sql/new) 11 | 2. Run these queries: 12 | **Create cleanup function** 13 | ```sql 14 | CREATE OR REPLACE FUNCTION public.cleanup_expired_records() 15 | RETURNS void 16 | LANGUAGE plpgsql 17 | SECURITY DEFINER SET SEARCH_PATH='' 18 | AS $$ 19 | DECLARE 20 | export_ids uuid[]; 21 | BEGIN 22 | -- Clean up expired verification codes 23 | DELETE FROM public.verification_codes WHERE expires_at < NOW(); 24 | 25 | -- Clean up expired device sessions 26 | DELETE FROM public.device_sessions WHERE expires_at < NOW(); 27 | 28 | -- Get IDs of completed exports older than 24 hours 29 | SELECT ARRAY_AGG(id) INTO export_ids 30 | FROM public.data_export_requests 31 | WHERE 32 | (status = 'completed' AND completed_at < NOW() - INTERVAL '24 hours') 33 | OR 34 | (status = 'pending' AND created_at < NOW() - INTERVAL '24 hours') 35 | OR 36 | (status = 'failed' AND updated_at < NOW() - INTERVAL '24 hours'); 37 | 38 | -- Delete the export files from storage 39 | -- Note: This requires the storage.objects table to exist 40 | IF export_ids IS NOT NULL THEN 41 | DELETE FROM storage.objects 42 | WHERE bucket_id = 'exports' 43 | AND path LIKE ANY ( 44 | SELECT 'exports/' || id::text || '%' 45 | FROM unnest(export_ids) AS id 46 | ); 47 | 48 | -- Clean up the export requests 49 | DELETE FROM public.data_export_requests 50 | WHERE id = ANY(export_ids); 51 | END IF; 52 | END; 53 | $$; 54 | ``` 55 | 56 | **Schedule cleanup function** 57 | ```sql 58 | SELECT cron.schedule( 59 | 'cleanup_database', -- Job name 60 | '0 0 * * *', -- Cron schedule for midnight 61 | $$ 62 | SELECT cleanup_expired_records(); 63 | $$ 64 | ); 65 | ``` 66 | 67 | ## What gets cleaned up? 68 | 69 | The cleanup function handles: 70 | 71 | 1. **Verification codes**: Removes expired verification codes 72 | 2. **Device sessions**: Removes expired device sessions 73 | 3. **Data exports**: Cleans up: 74 | - Completed exports older than 24 hours 75 | - Pending exports that haven't completed in 24 hours (likely stuck) 76 | - Failed exports older than 24 hours 77 | - Associated export files from Supabase Storage 78 | 79 | The cleanup runs automatically at midnight every day. -------------------------------------------------------------------------------- /docs/flow-questions.md: -------------------------------------------------------------------------------- 1 | ### 1.What's the point of email login alerts? 2 | An unknown device logging in to an account will need to verify: 3 | - with a code sent to an email 4 | - or 2FA if user enabled it 5 | 6 | Either way, some verification is needed. 7 | 8 | If they get through that, then what the fuck is the point of the login alerts? 9 | 10 | ### 2.Questions about 2FA and device verification 11 | So the idea at first was: 12 | 1. Unknown device logs in 13 | 2. Needs to verify it through email 14 | 15 | But, what if the user has 2FA enabled? 16 | - Will they need to verify with email AND 2FA? 17 | - Or just 2FA? 18 | 19 | How do we even define 2FA? 20 | - Is email verification 2FA? 21 | - Will disabling 2FA also get rid of device verification? 22 | - Does login alerts make more sense here? -------------------------------------------------------------------------------- /docs/plan.md: -------------------------------------------------------------------------------- 1 | # 1. Setup Device Detection (First) 2 | - [x] Implement user-agent parsing 3 | - [x] Extract device, browser, OS info 4 | - [x] Get IP address from headers 5 | - [x] Create device session record 6 | 7 | # 2. Implement Confidence Scoring 8 | - [x] Create scoring function 9 | - [x] Define score thresholds 10 | - [x] Handle OS version matching 11 | 12 | # 3. Access Level System 13 | - [x] Create access level types (types/auth.ts) 14 | - [x] Implement level determination 15 | - [ ] Add session restrictions 16 | - [ ] Handle Google vs Email auth 17 | 18 | # 4. Notification System 19 | - [ ] Create email templates 20 | - [ ] Setup in-app notifications 21 | - [ ] Trigger on all logins 22 | - [ ] Include device details 23 | 24 | # 5. Device Management UI 25 | - [ ] List active sessions 26 | - [ ] Show confidence levels 27 | - [ ] Enable session logout 28 | - [ ] Display login history 29 | 30 | -------------------------------------------------------------------------------- /docs/project-overview.md: -------------------------------------------------------------------------------- 1 | # Mazeway 2 | 3 | > This document serves as a memory/overview of the codebase for LLMs. It helps them understand the project structure, key features, and important implementation details. 4 | 5 | ## Introduction 6 | 7 | The open-source auth foundation that lives in your project, not a node_modules folder. 8 | 9 | **Core Features**: 10 | 11 | - Authentication 12 | - Email/password 13 | - Social providers (Google, GitHub) 14 | - Two-factor authentication (2FA) 15 | - Password reset flow 16 | - Device tracking 17 | - Account Security 18 | - Activity history 19 | - Security alerts 20 | - Device management 21 | - Data export (GDPR) 22 | - Account deletion 23 | - Verification System 24 | - 2FA methods 25 | - Backup codes 26 | - Password verification 27 | - Email verification 28 | - API Security 29 | - Rate limiting 30 | - CSRF protection 31 | - Input validation 32 | 33 | ## Tech Stack 34 | 35 | * Next.js 15 36 | * Tailwind 37 | * Shadcn 38 | * Supabase 39 | * Upstash Redis 40 | * Resend 41 | 42 | ## Core Architecture 43 | 44 | ### Authentication Layer 45 | - Custom auth flow with Supabase 46 | - Device session tracking 47 | - Social provider integration 48 | - Verification system 49 | - Rate limiting 50 | 51 | ### Security Layer 52 | - Device trust scoring 53 | - Session management 54 | - Event logging 55 | - Email alerts 56 | - Data protection 57 | 58 | ### User Management 59 | - Profile system 60 | - Security settings 61 | - Device control 62 | - Data portability 63 | - Account lifecycle 64 | 65 | ## Project Structure 66 | - `app/`: Next.js app router 67 | - `components/`: React components 68 | - `emails/`: Email templates 69 | - `utils/`: Core utilities 70 | - `config/`: App configuration 71 | 72 | ## Key Features 73 | 74 | ### Authentication System 75 | - Multiple sign-in methods 76 | - Enhanced verification flow 77 | - Device tracking 78 | - Session management 79 | 80 | ### Security Features 81 | - Multi-factor authentication 82 | - Device trust system 83 | - Rate limiting 84 | - Audit logging 85 | - Email alerts 86 | 87 | ### Account Management 88 | - Profile controls 89 | - Security settings 90 | - Device management 91 | - Data export 92 | - Account deletion 93 | 94 | ### Email System 95 | - Security alerts 96 | - Verification emails 97 | - Activity notifications 98 | 99 | ### Data Protection 100 | - GDPR compliance 101 | - Secure exports 102 | - Data cleanup 103 | - Access controls 104 | 105 | ### API Security 106 | - Request validation 107 | - Rate limiting 108 | - CSRF protection 109 | - Error handling -------------------------------------------------------------------------------- /docs/verification-example.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogDescription, 8 | DialogHeader, 9 | DialogTitle, 10 | } from "@/components/ui/dialog"; 11 | import { VerifyForm } from "@/components/verify-form"; 12 | import { TVerificationFactor } from "@/types/auth"; 13 | import { api } from "@/utils/api"; 14 | import { useState } from "react"; 15 | import { toast } from "sonner"; 16 | 17 | export default function VerificationExample() { 18 | const [needsVerification, setNeedsVerification] = useState(false); 19 | const [verificationData, setVerificationData] = useState<{ 20 | availableMethods: TVerificationFactor[]; 21 | } | null>(null); 22 | const [isDeleting, setIsDeleting] = useState(false); 23 | 24 | const handleDeleteAccount = async () => { 25 | try { 26 | setIsDeleting(true); 27 | 28 | // Attempt to delete account 29 | const data = await api.auth.deleteAccount(); 30 | 31 | // If verification is needed, show verification dialog 32 | if ( 33 | data.requiresVerification && 34 | data.availableMethods && 35 | data.availableMethods.length > 0 36 | ) { 37 | setVerificationData({ availableMethods: data.availableMethods }); 38 | setNeedsVerification(true); 39 | return; 40 | } 41 | 42 | toast.success("Account deleted successfully"); 43 | } catch (error) { 44 | console.error("Error during account deletion", error); 45 | toast.error("Error", { 46 | description: 47 | error instanceof Error ? error.message : "An error occurred", 48 | duration: 3000, 49 | }); 50 | } finally { 51 | setIsDeleting(false); 52 | setNeedsVerification(false); 53 | setVerificationData(null); 54 | } 55 | }; 56 | 57 | return ( 58 |
59 | 66 | {verificationData && verificationData.availableMethods && ( 67 | 68 | 69 | 70 | Verify your identity 71 | 72 | To delete your account, please verify your identity 73 | 74 | 75 | 79 | 80 | 81 | )} 82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /emails/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { Img } from "@react-email/components"; 2 | 3 | export function Header() { 4 | return ( 5 |
6 | {/* App logo */} 7 | Logo 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /emails/templates/data-export-ready.tsx: -------------------------------------------------------------------------------- 1 | // Email notification when a user's data export is ready for download 2 | 3 | import { 4 | Html, 5 | Head, 6 | Preview, 7 | Body, 8 | Container, 9 | Section, 10 | Heading, 11 | Text, 12 | Link, 13 | Button, 14 | } from "@react-email/components"; 15 | import { Header } from "../components/header"; 16 | import { AUTH_CONFIG } from "@/config/auth"; 17 | 18 | interface TDataExportReadyProps { 19 | email: string; 20 | downloadUrl: string; 21 | expiresInHours: number; 22 | } 23 | 24 | export default function DataExportReadyTemplate({ 25 | email = "user@example.com", 26 | downloadUrl = "https://example.com/download", 27 | expiresInHours = AUTH_CONFIG.dataExport.downloadExpirationTime, 28 | }: TDataExportReadyProps) { 29 | return ( 30 | 31 | 32 | Your data export is ready for download 33 | 40 | 41 |
42 | 43 | 53 | Your data export is ready 54 | 55 | 56 |
57 | 65 | Your requested data export for ({email}) is now ready for 66 | download. For security reasons, the download link will expire in{" "} 67 | {expiresInHours} hours. 68 | 69 |
70 | 71 |
80 | 88 | Download Link: 89 | 90 | 91 | 105 | 106 | 107 | Direct link:{" "} 108 | 115 | {downloadUrl} 116 | 117 | 118 |
119 | 120 | 128 | If this wasn't you, please secure your account immediately by{" "} 129 | 133 | changing your password 134 | 135 | 136 | 137 | 138 | 139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /emails/templates/data-export-requested.tsx: -------------------------------------------------------------------------------- 1 | // Email notification when a user requests a data export 2 | 3 | import { 4 | Html, 5 | Head, 6 | Preview, 7 | Body, 8 | Container, 9 | Section, 10 | Heading, 11 | Text, 12 | Link, 13 | } from "@react-email/components"; 14 | import { Header } from "../components/header"; 15 | import { TDeviceInfo } from "@/types/auth"; 16 | 17 | interface TDataExportRequestedProps { 18 | email: string; 19 | device?: TDeviceInfo; 20 | } 21 | 22 | export default function DataExportRequestedTemplate({ 23 | email = "user@example.com", 24 | device, 25 | }: TDataExportRequestedProps) { 26 | return ( 27 | 28 | 29 | Your data export request has been received 30 | 37 | 38 |
39 | 40 | 50 | Data export request received 51 | 52 | 53 |
54 | 62 | We've received a request to export your account data ({email}). 63 | We'll process this request and send you another email when your 64 | data is ready to download. 65 | 66 |
67 | 68 | {/* Show device details if provided */} 69 | {device && ( 70 |
79 | 87 | Device Details: 88 | 89 | 90 | Device: {device.device_name} 91 | 92 | {device.browser && ( 93 | 94 | Browser: {device.browser} 95 | 96 | )} 97 | {device.os && ( 98 | 99 | Operating System: {device.os} 100 | 101 | )} 102 | {device.ip_address && ( 103 | 104 | IP Address: {device.ip_address} 105 | 106 | )} 107 |
108 | )} 109 | 110 | 118 | If this wasn't you, please secure your account immediately by{" "} 119 | 123 | changing your password 124 | 125 | 126 | 127 | 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /emails/templates/device-verification.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from "../components/header"; 2 | import { 3 | Body, 4 | Container, 5 | Html, 6 | Section, 7 | Text, 8 | Heading, 9 | } from "@react-email/components"; 10 | import { AUTH_CONFIG } from "@/config/auth"; 11 | 12 | export default function DeviceVerificationEmail({ 13 | code = "000000", 14 | device_name = "Unknown Device", 15 | expires_in = `${AUTH_CONFIG.deviceVerification.codeExpirationTime} minutes`, 16 | }: { 17 | code?: string; 18 | device_name?: string; 19 | expires_in?: string; 20 | }) { 21 | return ( 22 | 23 | 30 | 31 |
32 | 33 | 43 | Verify Your Device 44 | 45 | 46 |
47 | 54 | We noticed a login attempt from a new device ({device_name}). To 55 | verify this device, please use the code below: 56 | 57 |
58 | 59 |
68 | 78 | {code} 79 | 80 |
81 | 82 | 90 | This code will expire in {expires_in}. If you did not attempt to log 91 | in, please ignore this email. 92 | 93 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /emails/templates/email-verification.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from "../components/header"; 2 | import { 3 | Body, 4 | Container, 5 | Html, 6 | Section, 7 | Text, 8 | Heading, 9 | } from "@react-email/components"; 10 | import { AUTH_CONFIG } from "@/config/auth"; 11 | 12 | export default function EmailVerificationTemplate({ 13 | code = "000000", 14 | expires_in = `${AUTH_CONFIG.emailVerification.codeExpirationTime} minutes`, 15 | }: { 16 | code?: string; 17 | expires_in?: string; 18 | }) { 19 | return ( 20 | 21 | 28 | 29 |
30 | 31 | 41 | Verification Code 42 | 43 | 44 |
45 | 52 | To complete your verification, please use the code below: 53 | 54 |
55 | 56 |
65 | 75 | {code} 76 | 77 |
78 | 79 | 87 | This code will expire in {expires_in}. If you did not request this 88 | code, please ignore this email. 89 | 90 | 91 | 92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | // Cloudinary CDN 7 | { 8 | protocol: "https", 9 | hostname: "res.cloudinary.com", 10 | port: "", 11 | pathname: "/**", 12 | }, 13 | ], 14 | }, 15 | compiler: { 16 | removeConsole: process.env.NODE_ENV === "production", 17 | }, 18 | reactStrictMode: true, 19 | productionBrowserSourceMaps: true, 20 | }; 21 | 22 | export default nextConfig; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth-starterpack", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "reset-project": "bun scripts/reset-project.ts" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^3.9.1", 14 | "@radix-ui/react-accordion": "^1.2.1", 15 | "@radix-ui/react-alert-dialog": "^1.1.2", 16 | "@radix-ui/react-aspect-ratio": "^1.1.0", 17 | "@radix-ui/react-avatar": "^1.1.1", 18 | "@radix-ui/react-checkbox": "^1.1.2", 19 | "@radix-ui/react-collapsible": "^1.1.1", 20 | "@radix-ui/react-context-menu": "^2.2.2", 21 | "@radix-ui/react-dialog": "^1.1.6", 22 | "@radix-ui/react-dropdown-menu": "^2.1.2", 23 | "@radix-ui/react-hover-card": "^1.1.2", 24 | "@radix-ui/react-label": "^2.1.0", 25 | "@radix-ui/react-menubar": "^1.1.2", 26 | "@radix-ui/react-navigation-menu": "^1.2.1", 27 | "@radix-ui/react-popover": "^1.1.2", 28 | "@radix-ui/react-progress": "^1.1.0", 29 | "@radix-ui/react-radio-group": "^1.2.1", 30 | "@radix-ui/react-scroll-area": "^1.2.1", 31 | "@radix-ui/react-select": "^2.1.2", 32 | "@radix-ui/react-separator": "^1.1.0", 33 | "@radix-ui/react-slider": "^1.2.1", 34 | "@radix-ui/react-slot": "^1.1.0", 35 | "@radix-ui/react-switch": "^1.1.1", 36 | "@radix-ui/react-tabs": "^1.1.1", 37 | "@radix-ui/react-toast": "^1.2.2", 38 | "@radix-ui/react-toggle": "^1.1.0", 39 | "@radix-ui/react-toggle-group": "^1.1.0", 40 | "@radix-ui/react-tooltip": "^1.1.5", 41 | "@react-email/components": "^0.0.31", 42 | "@react-email/render": "^1.0.3", 43 | "@supabase/ssr": "^0.5.2", 44 | "@supabase/supabase-js": "^2.48.1", 45 | "@trigger.dev/sdk": "^3.3.17", 46 | "@types/qrcode": "^1.5.5", 47 | "@upstash/ratelimit": "^2.0.5", 48 | "@upstash/redis": "^1.34.3", 49 | "@vercel/analytics": "^1.4.1", 50 | "bip39": "^3.1.0", 51 | "class-variance-authority": "^0.7.1", 52 | "clsx": "^2.1.1", 53 | "cmdk": "^1.0.0", 54 | "date-fns": "^3.6.0", 55 | "embla-carousel-react": "^8.5.1", 56 | "framer-motion": "^11.13.1", 57 | "input-otp": "^1.4.1", 58 | "lucide-react": "^0.468.0", 59 | "next": "^15.2.0", 60 | "next-themes": "^0.4.4", 61 | "qrcode": "^1.5.4", 62 | "react": "^18.2.0", 63 | "react-day-picker": "^8.10.1", 64 | "react-dom": "^18.2.0", 65 | "react-hook-form": "^7.54.0", 66 | "react-icons": "^5.4.0", 67 | "react-phone-number-input": "^3.4.11", 68 | "react-resizable-panels": "^2.1.7", 69 | "recharts": "^2.14.1", 70 | "resend": "^4.0.1", 71 | "sonner": "^1.7.1", 72 | "swr": "^2.3.2", 73 | "tailwind-merge": "^2.5.5", 74 | "tailwindcss-animate": "^1.0.7", 75 | "ua-parser-js": "^2.0.0", 76 | "vaul": "^1.1.1", 77 | "zod": "^3.23.8", 78 | "zustand": "^5.0.2" 79 | }, 80 | "devDependencies": { 81 | "@types/bun": "latest", 82 | "@trigger.dev/build": "^3.3.17", 83 | "@types/react": "^18.2.48", 84 | "@types/react-dom": "^18.2.18", 85 | "postcss": "^8", 86 | "prettier": "^3.4.2", 87 | "tailwindcss": "^3.4.1", 88 | "tsx": "^4.7.1", 89 | "typescript": "^5" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/app/account/data/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DataExport } from "@/components/data-export"; 4 | 5 | export default function DataPage() { 6 | return ( 7 |
8 |

Your data

9 | 10 |
11 |

Data exports

12 |

13 | Download a copy of your personal data. We'll email you when it's 14 | ready. 15 |

16 |
17 | 18 |
19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/account/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Header } from "@/components/header"; 4 | import { 5 | SidebarProvider, 6 | Sidebar, 7 | SidebarContent, 8 | SidebarGroup, 9 | SidebarGroupLabel, 10 | SidebarGroupContent, 11 | SidebarMenu, 12 | SidebarMenuItem, 13 | SidebarMenuButton, 14 | SidebarTrigger, 15 | useSidebar, 16 | } from "@/components/ui/sidebar"; 17 | import { KeyRound, User, DatabaseIcon } from "lucide-react"; 18 | import Link from "next/link"; 19 | import { usePathname } from "next/navigation"; 20 | import { AUTH_CONFIG } from "@/config/auth"; 21 | 22 | export default function AccountLayout({ 23 | children, 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | return ( 28 | 29 |
30 |
} 33 | /> 34 |
35 | 36 |
37 |
{children}
38 |
39 |
40 |
41 |
42 | ); 43 | } 44 | 45 | function SettingsSidebar() { 46 | const pathname = usePathname(); 47 | const { setOpenMobile, isMobile } = useSidebar(); 48 | 49 | const items = [ 50 | { 51 | title: "Account", 52 | href: "/account", 53 | icon: User, 54 | }, 55 | { 56 | title: "Security", 57 | href: "/account/security", 58 | icon: KeyRound, 59 | }, 60 | ]; 61 | 62 | // Only add data section if data exports are enabled 63 | if (AUTH_CONFIG.dataExport.enabled) { 64 | items.push({ 65 | title: "Data", 66 | href: "/account/data", 67 | icon: DatabaseIcon, 68 | }); 69 | } 70 | 71 | const handleLinkClick = () => { 72 | if (isMobile) { 73 | setOpenMobile(false); 74 | } 75 | }; 76 | 77 | return ( 78 | 79 | 80 | 81 | Settings 82 | 83 | 84 | {items.map((item) => { 85 | const isActive = 86 | pathname === item.href || pathname === `${item.href}/`; 87 | 88 | return ( 89 | 90 | 91 | 92 | 93 | {item.title} 94 | 95 | 96 | 97 | ); 98 | })} 99 | 100 | 101 | 102 | 103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/app/api/auth/data-exports/[id]/route.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Gets the status of an export request 3 | */ 4 | 5 | import { createClient } from "@/utils/supabase/server"; 6 | import { NextRequest, NextResponse } from "next/server"; 7 | import { getUser } from "@/utils/auth"; 8 | import { getDataExportStatus } from "@/utils/data-export/server"; 9 | import { TApiErrorResponse, TGetDataExportStatusResponse } from "@/types/api"; 10 | import { AUTH_CONFIG } from "@/config/auth"; 11 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 12 | 13 | export async function GET(request: NextRequest) { 14 | try { 15 | // Check if data exports are enabled 16 | if (!AUTH_CONFIG.dataExport.enabled) { 17 | return NextResponse.json( 18 | { error: "Data exports are not enabled" }, 19 | { status: 400 } 20 | ) satisfies NextResponse; 21 | } 22 | 23 | // Apply rate limiting 24 | if (authRateLimit) { 25 | const ip = getClientIp(request); 26 | const { success } = await authRateLimit.limit(ip); 27 | 28 | if (!success) { 29 | return NextResponse.json( 30 | { error: "Too many requests. Please try again later." }, 31 | { status: 429 } 32 | ) satisfies NextResponse; 33 | } 34 | } 35 | 36 | // Get the user 37 | const supabase = await createClient(); 38 | const supabaseAdmin = await createClient({ useServiceRole: true }); 39 | 40 | const { user, error } = await getUser({ supabase }); 41 | 42 | if (error || !user) { 43 | return NextResponse.json( 44 | { error: "Unauthorized" }, 45 | { status: 401 } 46 | ) satisfies NextResponse; 47 | } 48 | 49 | // Get the export ID from the URL 50 | const pathParts = request.nextUrl.pathname.split("/"); 51 | const exportId = pathParts[pathParts.length - 1]; // Last segment for status route 52 | 53 | if (!exportId) { 54 | return NextResponse.json( 55 | { error: "Missing export ID" }, 56 | { status: 400 } 57 | ) satisfies NextResponse; 58 | } 59 | 60 | // Get the export status 61 | const exportRequest = await getDataExportStatus( 62 | supabaseAdmin, 63 | user.id, 64 | exportId 65 | ); 66 | 67 | if (!exportRequest) { 68 | return NextResponse.json( 69 | { error: "Export request not found" }, 70 | { status: 404 } 71 | ) satisfies NextResponse; 72 | } 73 | 74 | // Return the status 75 | return NextResponse.json({ 76 | id: exportRequest.id, 77 | status: exportRequest.status, 78 | created_at: exportRequest.created_at, 79 | completed_at: exportRequest.completed_at, 80 | }) satisfies NextResponse; 81 | } catch (error) { 82 | console.error("Error getting data export status:", error); 83 | return NextResponse.json( 84 | { error: "Failed to get data export status" }, 85 | { status: 500 } 86 | ) satisfies NextResponse; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app/api/auth/device-sessions/current/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { TApiErrorResponse, TGetDeviceSessionResponse } from "@/types/api"; 4 | import { apiRateLimit, getClientIp } from "@/utils/rate-limit"; 5 | import { getUser } from "@/utils/auth"; 6 | import { getCurrentDeviceSessionId } from "@/utils/auth/device-sessions"; 7 | 8 | /** 9 | * Returns the current device session for the authenticated user. 10 | * Uses the device_session_id cookie to identify the current session. 11 | */ 12 | export async function GET(request: NextRequest) { 13 | if (apiRateLimit) { 14 | const ip = getClientIp(request); 15 | const { success } = await apiRateLimit.limit(ip); 16 | 17 | if (!success) { 18 | return NextResponse.json( 19 | { 20 | error: "Too many requests. Please try again later.", 21 | }, 22 | { status: 429 } 23 | ) satisfies NextResponse; 24 | } 25 | } 26 | 27 | const supabase = await createClient(); 28 | 29 | try { 30 | // First security layer: Validate auth token 31 | const { user, error } = await getUser({ supabase }); 32 | if (error || !user) throw new Error("Unauthorized"); 33 | 34 | // Get the current session ID from cookie 35 | const sessionId = getCurrentDeviceSessionId(request); 36 | if (!sessionId) throw new Error("No device session found"); 37 | 38 | // Get the session data 39 | const { data: session, error: sessionError } = await supabase 40 | .from("device_sessions") 41 | .select( 42 | ` 43 | *, 44 | device:devices(*) 45 | ` 46 | ) 47 | .eq("id", sessionId) 48 | .eq("user_id", user.id) 49 | .single(); 50 | 51 | if (sessionError || !session) { 52 | throw new Error("Session not found or unauthorized"); 53 | } 54 | 55 | return NextResponse.json({ 56 | data: session, 57 | }) satisfies NextResponse; 58 | } catch (error) { 59 | const err = error as Error; 60 | return NextResponse.json( 61 | { error: err.message }, 62 | { 63 | status: 64 | error instanceof Error && 65 | (error.message === "Unauthorized" || 66 | error.message === "Session not found or unauthorized" || 67 | error.message === "No device session found") 68 | ? 401 69 | : 500, 70 | } 71 | ) satisfies NextResponse; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/api/auth/device-sessions/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { TApiErrorResponse, TGetDeviceSessionsResponse } from "@/types/api"; 4 | import { apiRateLimit, getClientIp } from "@/utils/rate-limit"; 5 | import { getUser } from "@/utils/auth"; 6 | 7 | export async function GET(request: NextRequest) { 8 | try { 9 | if (apiRateLimit) { 10 | const ip = getClientIp(request); 11 | const { success } = await apiRateLimit.limit(ip); 12 | 13 | if (!success) { 14 | return NextResponse.json( 15 | { error: "Too many requests. Please try again later." }, 16 | { status: 429 } 17 | ) satisfies NextResponse; 18 | } 19 | } 20 | 21 | const supabase = await createClient(); 22 | const { user, error } = await getUser({ supabase }); 23 | if (error || !user) throw new Error("Unauthorized"); 24 | 25 | const { data, error: supabaseError } = await supabase 26 | .from("device_sessions") 27 | .select( 28 | ` 29 | *, 30 | device:devices(*) 31 | ` 32 | ) 33 | .eq("user_id", user.id) 34 | .order("created_at", { ascending: false }); 35 | 36 | if (supabaseError) throw supabaseError; 37 | 38 | return NextResponse.json({ 39 | data, 40 | }) satisfies NextResponse; 41 | } catch (error) { 42 | const err = error as Error; 43 | return NextResponse.json( 44 | { error: err.message }, 45 | { 46 | status: 47 | error instanceof Error && error.message === "Unauthorized" 48 | ? 401 49 | : 500, 50 | } 51 | ) satisfies NextResponse; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/api/auth/device-sessions/trusted/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { 4 | TApiErrorResponse, 5 | TGetTrustedDeviceSessionsResponse, 6 | } from "@/types/api"; 7 | import { apiRateLimit, getClientIp } from "@/utils/rate-limit"; 8 | import { getUser } from "@/utils/auth"; 9 | 10 | /** 11 | * Returns all trusted device sessions for the authenticated user. 12 | * A session is considered trusted if it has been explicitly marked as trusted 13 | * during session creation based on device confidence and verification status. 14 | */ 15 | export async function GET(request: NextRequest) { 16 | console.log("[AUTH] /api/auth/device-sessions/trusted - Request received", { 17 | url: request.url, 18 | timestamp: new Date().toISOString(), 19 | headers: { 20 | cookie: !!request.headers.get("cookie"), 21 | useragent: request.headers.get("user-agent")?.substring(0, 50) + "...", 22 | }, 23 | }); 24 | 25 | if (apiRateLimit) { 26 | const ip = getClientIp(request); 27 | console.log( 28 | "[AUTH] /api/auth/device-sessions/trusted - Rate limiting check", 29 | { 30 | ip: ip, 31 | } 32 | ); 33 | 34 | const { success } = await apiRateLimit.limit(ip); 35 | 36 | if (!success) { 37 | console.error( 38 | "[AUTH] /api/auth/device-sessions/trusted - Rate limit exceeded", 39 | { 40 | ip: ip, 41 | } 42 | ); 43 | 44 | return NextResponse.json( 45 | { 46 | error: "Too many requests. Please try again later.", 47 | }, 48 | { status: 429 } 49 | ) satisfies NextResponse; 50 | } 51 | } 52 | 53 | const supabase = await createClient(); 54 | 55 | try { 56 | console.log( 57 | "[AUTH] /api/auth/device-sessions/trusted - Getting user from auth" 58 | ); 59 | 60 | const { user, error } = await getUser({ supabase }); 61 | 62 | if (error) { 63 | console.error("[AUTH] /api/auth/device-sessions/trusted - User error", { 64 | error, 65 | }); 66 | throw new Error("Unauthorized"); 67 | } 68 | 69 | if (!user) { 70 | console.error( 71 | "[AUTH] /api/auth/device-sessions/trusted - No user found in session" 72 | ); 73 | throw new Error("Unauthorized"); 74 | } 75 | 76 | console.log( 77 | "[AUTH] /api/auth/device-sessions/trusted - User authenticated", 78 | { 79 | userId: user.id, 80 | email: user.email, 81 | } 82 | ); 83 | 84 | console.log( 85 | "[AUTH] /api/auth/device-sessions/trusted - Fetching trusted device sessions" 86 | ); 87 | 88 | const { data, error: supabaseError } = await supabase 89 | .from("device_sessions") 90 | .select( 91 | ` 92 | *, 93 | device:devices(*) 94 | ` 95 | ) 96 | .eq("user_id", user.id) 97 | .eq("is_trusted", true) 98 | .order("created_at", { ascending: false }); 99 | 100 | if (supabaseError) { 101 | console.error( 102 | "[AUTH] /api/auth/device-sessions/trusted - Database error", 103 | { 104 | error: supabaseError.message, 105 | code: supabaseError.code, 106 | } 107 | ); 108 | throw supabaseError; 109 | } 110 | 111 | console.log( 112 | "[AUTH] /api/auth/device-sessions/trusted - Sessions fetched successfully", 113 | { 114 | count: data ? data.length : 0, 115 | } 116 | ); 117 | 118 | return NextResponse.json({ 119 | data, 120 | }) satisfies NextResponse; 121 | } catch (error) { 122 | const err = error as Error; 123 | console.error("Error fetching trusted device sessions:", { 124 | error: err.message, 125 | stack: err.stack, 126 | }); 127 | 128 | return NextResponse.json( 129 | { error: err.message }, 130 | { 131 | status: 132 | error instanceof Error && error.message === "Unauthorized" 133 | ? 401 134 | : 500, 135 | } 136 | ) satisfies NextResponse; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/app/api/auth/email/check/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 4 | import { authSchema } from "@/validation/auth-validation"; 5 | import { 6 | TApiErrorResponse, 7 | TCheckEmailRequest, 8 | TCheckEmailResponse, 9 | } from "@/types/api"; 10 | 11 | export async function POST(request: NextRequest) { 12 | try { 13 | if (authRateLimit) { 14 | const ip = getClientIp(request); 15 | const { success } = await authRateLimit.limit(ip); 16 | 17 | if (!success) { 18 | return NextResponse.json( 19 | { error: "Too many requests. Please try again later." }, 20 | { status: 429 } 21 | ) satisfies NextResponse; 22 | } 23 | } 24 | 25 | const rawBody = await request.json(); 26 | const validation = authSchema.shape.email.safeParse(rawBody.email); 27 | 28 | if (!validation.success) { 29 | return NextResponse.json( 30 | { error: validation.error.issues[0]?.message || "Invalid email" }, 31 | { status: 400 } 32 | ) satisfies NextResponse; 33 | } 34 | 35 | const body: TCheckEmailRequest = { email: validation.data }; 36 | 37 | // Use service role to check user existence 38 | const adminClient = await createClient({ useServiceRole: true }); 39 | 40 | // Query the users table directly 41 | const { data, error } = await adminClient 42 | .from("users") 43 | .select("id") 44 | .eq("email", body.email) 45 | .single(); 46 | 47 | if (error && error.code !== "PGRST116") { 48 | // PGRST116 is "no rows returned" 49 | return NextResponse.json( 50 | { error: "Failed to check email" }, 51 | { status: 500 } 52 | ) satisfies NextResponse; 53 | } 54 | 55 | return NextResponse.json({ 56 | exists: !!data, 57 | }) satisfies NextResponse; 58 | } catch (error) { 59 | console.error("Email check error:", error); 60 | return NextResponse.json( 61 | { error: "Internal server error" }, 62 | { status: 500 } 63 | ) satisfies NextResponse; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/api/auth/email/login/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 4 | import { 5 | TApiErrorResponse, 6 | TEmailLoginRequest, 7 | TEmailLoginResponse, 8 | } from "@/types/api"; 9 | import { getUserVerificationMethods } from "@/utils/auth"; 10 | import { authSchema } from "@/validation/auth-validation"; 11 | 12 | export async function POST(request: NextRequest) { 13 | try { 14 | if (authRateLimit) { 15 | const ip = getClientIp(request); 16 | const { success } = await authRateLimit.limit(ip); 17 | 18 | if (!success) { 19 | console.warn("[AUTH] Rate limit exceeded for IP:", ip); 20 | return NextResponse.json( 21 | { error: "Too many requests. Please try again later." }, 22 | { status: 429 } 23 | ) satisfies NextResponse; 24 | } 25 | } 26 | 27 | const { origin } = new URL(request.url); 28 | const redirectUrl = `${origin}/api/auth/post-auth?provider=email&next=/`; 29 | 30 | // Parse and validate request body 31 | const rawBody = await request.json(); 32 | const validation = authSchema.safeParse(rawBody); 33 | 34 | if (!validation.success) { 35 | console.warn("[AUTH] Login validation failed:", validation.error.issues); 36 | return NextResponse.json( 37 | { error: validation.error.issues[0]?.message || "Invalid input" }, 38 | { status: 400 } 39 | ) satisfies NextResponse; 40 | } 41 | 42 | const body: TEmailLoginRequest = validation.data; 43 | 44 | const supabase = await createClient(); 45 | const supabaseAdmin = await createClient({ useServiceRole: true }); 46 | 47 | const { error: authError } = await supabase.auth.signInWithPassword({ 48 | email: body.email, 49 | password: body.password, 50 | }); 51 | 52 | if (authError) { 53 | console.warn("[AUTH] Login failed:", authError); 54 | // Generic error message for any auth failure 55 | return NextResponse.json( 56 | { error: "Invalid email or password" }, 57 | { status: 400 } 58 | ) satisfies NextResponse; 59 | } 60 | 61 | try { 62 | // Check if user has 2FA enabled 63 | const { has2FA, methods, factors } = await getUserVerificationMethods({ 64 | supabase, 65 | supabaseAdmin, 66 | }); 67 | 68 | if (has2FA) { 69 | // Get first available 2FA method and its factor 70 | const factor = factors.find((f) => methods.includes(f.type)); 71 | if (!factor) { 72 | console.error( 73 | "[AUTH] No valid 2FA methods found for user with 2FA enabled" 74 | ); 75 | throw new Error("No valid 2FA methods found"); 76 | } 77 | 78 | return NextResponse.json({ 79 | requiresTwoFactor: true, 80 | availableMethods: factors, 81 | redirectTo: redirectUrl, 82 | }) satisfies NextResponse; 83 | } 84 | } catch (error) { 85 | console.error("[AUTH] Error checking 2FA status:", error); 86 | return NextResponse.json( 87 | { error: "Failed to check 2FA status" }, 88 | { status: 500 } 89 | ) satisfies NextResponse; 90 | } 91 | 92 | // If no 2FA required or not configured, proceed with login 93 | return NextResponse.redirect(redirectUrl, { 94 | status: 302, // Using 302 to ensure it's treated as a GET request 95 | }); 96 | } catch (error) { 97 | console.error("[AUTH] Unexpected login error:", error); 98 | return NextResponse.json( 99 | { error: "Internal server error" }, 100 | { status: 500 } 101 | ) satisfies NextResponse; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/app/api/auth/email/resend-confirmation/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 4 | 5 | export async function POST(request: NextRequest) { 6 | try { 7 | if (authRateLimit) { 8 | const ip = getClientIp(request); 9 | const { success } = await authRateLimit.limit(ip); 10 | 11 | if (!success) { 12 | return NextResponse.json( 13 | { error: "Too many requests. Please try again later." }, 14 | { status: 429 } 15 | ); 16 | } 17 | } 18 | 19 | const body = await request.json(); 20 | const { email } = body; 21 | 22 | if (!email) { 23 | return NextResponse.json({ error: "Email is required" }, { status: 400 }); 24 | } 25 | 26 | const supabase = await createClient(); 27 | const { error } = await supabase.auth.resend({ 28 | type: "signup", 29 | email: email, 30 | }); 31 | 32 | if (error) { 33 | return NextResponse.json({ error: error.message }, { status: 400 }); 34 | } 35 | 36 | return NextResponse.json({}, { status: 200 }); 37 | } catch (error) { 38 | return NextResponse.json( 39 | { error: "Internal server error" }, 40 | { status: 500 } 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/api/auth/email/send-verification/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { TApiErrorResponse, TEmptySuccessResponse } from "@/types/api"; 4 | import { Resend } from "resend"; 5 | import EmailVerificationTemplate from "@emails/templates/email-verification"; 6 | import { AUTH_CONFIG } from "@/config/auth"; 7 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 8 | import { generateVerificationCode } from "@/utils/auth/verification-codes"; 9 | import { getUser } from "@/utils/auth"; 10 | import { getCurrentDeviceSessionId } from "@/utils/auth/device-sessions"; 11 | 12 | export async function POST(request: NextRequest) { 13 | if (authRateLimit) { 14 | const ip = getClientIp(request); 15 | const { success } = await authRateLimit.limit(ip); 16 | 17 | if (!success) { 18 | return NextResponse.json( 19 | { error: "Too many requests. Please try again later." }, 20 | { status: 429 } 21 | ) satisfies NextResponse; 22 | } 23 | } 24 | 25 | if (!process.env.RESEND_API_KEY) { 26 | return NextResponse.json( 27 | { error: "Resend is not configured" }, 28 | { status: 500 } 29 | ) satisfies NextResponse; 30 | } 31 | 32 | try { 33 | const supabase = await createClient(); 34 | const { user, error } = await getUser({ supabase }); 35 | if (error || !user) { 36 | return NextResponse.json( 37 | { error: "Unauthorized" }, 38 | { status: 401 } 39 | ) satisfies NextResponse; 40 | } 41 | 42 | // Get device session ID from cookie 43 | const deviceSessionId = getCurrentDeviceSessionId(request); 44 | if (!deviceSessionId) { 45 | return NextResponse.json( 46 | { error: "No device session found" }, 47 | { status: 400 } 48 | ) satisfies NextResponse; 49 | } 50 | 51 | const { code, hash, salt } = await generateVerificationCode({ 52 | format: "alphanumeric", 53 | alphanumericLength: AUTH_CONFIG.emailVerification.codeLength, 54 | }); 55 | 56 | console.log("Generated code:", code); 57 | 58 | const expiresAt = new Date(); 59 | expiresAt.setMinutes( 60 | expiresAt.getMinutes() + AUTH_CONFIG.emailVerification.codeExpirationTime 61 | ); 62 | 63 | // Store verification code 64 | const adminClient = await createClient({ useServiceRole: true }); 65 | const { error: insertError } = await adminClient 66 | .from("verification_codes") 67 | .insert({ 68 | device_session_id: deviceSessionId, 69 | code_hash: hash, 70 | salt, 71 | expires_at: expiresAt.toISOString(), 72 | }); 73 | 74 | if (insertError) { 75 | console.error("Failed to store verification code:", insertError); 76 | return NextResponse.json( 77 | { error: "Failed to generate verification code" }, 78 | { status: 500 } 79 | ) satisfies NextResponse; 80 | } 81 | 82 | // Send email with code 83 | const resend = new Resend(process.env.RESEND_API_KEY); 84 | await resend.emails.send({ 85 | from: process.env.RESEND_FROM_EMAIL!, 86 | to: user.email!, 87 | subject: "Verification Code", 88 | react: EmailVerificationTemplate({ 89 | code, 90 | expires_in: `${AUTH_CONFIG.emailVerification.codeExpirationTime} minutes`, 91 | }), 92 | }); 93 | 94 | return NextResponse.json( 95 | {}, 96 | { status: 200 } 97 | ) satisfies NextResponse; 98 | } catch (error) { 99 | console.error("Error sending verification code:", error); 100 | return NextResponse.json( 101 | { error: "Failed to send verification code" }, 102 | { status: 500 } 103 | ) satisfies NextResponse; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/app/api/auth/email/signup/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { authSchema } from "@/validation/auth-validation"; 3 | import { NextRequest, NextResponse } from "next/server"; 4 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 5 | import { 6 | TApiErrorResponse, 7 | TEmailSignupRequest, 8 | TEmptySuccessResponse, 9 | } from "@/types/api"; 10 | 11 | export async function POST(request: NextRequest) { 12 | try { 13 | if (authRateLimit) { 14 | const ip = getClientIp(request); 15 | const { success } = await authRateLimit.limit(ip); 16 | 17 | if (!success) { 18 | return NextResponse.json( 19 | { error: "Too many requests. Please try again later." }, 20 | { status: 429 } 21 | ) satisfies NextResponse; 22 | } 23 | } 24 | 25 | const rawBody = await request.json(); 26 | const validation = authSchema.safeParse(rawBody); 27 | 28 | if (!validation.success) { 29 | return NextResponse.json( 30 | { error: validation.error.issues[0]?.message || "Invalid input" }, 31 | { status: 400 } 32 | ) satisfies NextResponse; 33 | } 34 | 35 | const body: TEmailSignupRequest = validation.data; 36 | 37 | // Check if email exists in our database using service role to bypass RLS 38 | const adminClient = await createClient({ useServiceRole: true }); 39 | const { data: existingUser } = await adminClient 40 | .from("users") 41 | .select("id") 42 | .eq("email", body.email) 43 | .single(); 44 | 45 | if (existingUser) { 46 | return NextResponse.json( 47 | { 48 | error: "This email is already in use. Please try logging in instead.", 49 | }, 50 | { status: 400 } 51 | ) satisfies NextResponse; 52 | } 53 | 54 | const supabase = await createClient(); 55 | const { data: signupData, error: authError } = await supabase.auth.signUp({ 56 | email: body.email, 57 | password: body.password, 58 | }); 59 | 60 | // Double-check Supabase's auth system for existing users 61 | if (authError) { 62 | console.error("Signup error:", authError); 63 | if (authError.code === "user_already_exists") { 64 | return NextResponse.json( 65 | { 66 | error: 67 | "This email is already registered. Please try logging in instead.", 68 | }, 69 | { status: 400 } 70 | ) satisfies NextResponse; 71 | } 72 | return NextResponse.json( 73 | { error: authError.message }, 74 | { status: 400 } 75 | ) satisfies NextResponse; 76 | } 77 | 78 | return NextResponse.json( 79 | {}, 80 | { status: 200 } 81 | ) satisfies NextResponse; 82 | } catch (error) { 83 | console.error("Error in signup:", error); 84 | return NextResponse.json( 85 | { error: "Internal server error" }, 86 | { status: 500 } 87 | ) satisfies NextResponse; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/api/auth/forgot-password/route.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This route is for non-authenticated users. 3 | * It doesn't reset the password, it just sends an email to the user with a link to change their password. 4 | * The implementation of the password change is in /api/auth/reset-password. 5 | */ 6 | 7 | import { NextRequest, NextResponse } from "next/server"; 8 | import { createClient } from "@/utils/supabase/server"; 9 | import { authSchema } from "@/validation/auth-validation"; 10 | import { 11 | TApiErrorResponse, 12 | TEmptySuccessResponse, 13 | TForgotPasswordRequest, 14 | } from "@/types/api"; 15 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 16 | 17 | export async function POST(request: NextRequest) { 18 | try { 19 | if (authRateLimit) { 20 | const ip = getClientIp(request); 21 | const { success } = await authRateLimit.limit(ip); 22 | 23 | if (!success) { 24 | return NextResponse.json( 25 | { error: "Too many requests. Please try again later." }, 26 | { status: 429 } 27 | ) satisfies NextResponse; 28 | } 29 | } 30 | 31 | const { origin } = new URL(request.url); 32 | 33 | // Get and validate request body 34 | const rawBody = await request.json(); 35 | const validation = authSchema.shape.email.safeParse(rawBody.email); 36 | 37 | if (!validation.success) { 38 | return NextResponse.json( 39 | { error: validation.error.issues[0]?.message || "Invalid email" }, 40 | { status: 400 } 41 | ) satisfies NextResponse; 42 | } 43 | 44 | const body: TForgotPasswordRequest = { email: validation.data }; 45 | 46 | const supabase = await createClient(); 47 | 48 | // Send reset password email 49 | const { error } = await supabase.auth.resetPasswordForEmail(body.email, { 50 | redirectTo: `${origin}/auth/reset-password`, 51 | }); 52 | 53 | if (error) { 54 | // Don't expose this error 55 | console.error("Failed to send reset password email:", error); 56 | } 57 | 58 | // Always return success to prevent email enumeration 59 | return NextResponse.json( 60 | {}, 61 | { status: 200 } 62 | ) satisfies NextResponse; 63 | } catch (error) { 64 | console.error("Error in forgot password:", error); 65 | return NextResponse.json( 66 | { error: "An unexpected error occurred" }, 67 | { status: 500 } 68 | ) satisfies NextResponse; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/api/auth/github/signin/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 4 | import { TApiErrorResponse } from "@/types/api"; 5 | import { AUTH_CONFIG } from "@/config/auth"; 6 | 7 | export async function POST(request: NextRequest) { 8 | try { 9 | // Check if GitHub auth is enabled in the config 10 | if (!AUTH_CONFIG.socialProviders.github.enabled) { 11 | return NextResponse.json( 12 | { error: "GitHub authentication is disabled" }, 13 | { status: 403 } 14 | ) satisfies NextResponse; 15 | } 16 | 17 | if (authRateLimit) { 18 | const ip = getClientIp(request); 19 | const { success } = await authRateLimit.limit(ip); 20 | 21 | if (!success) { 22 | return NextResponse.json( 23 | { error: "Too many requests. Please try again later." }, 24 | { status: 429 } 25 | ) satisfies NextResponse; 26 | } 27 | } 28 | 29 | const { origin } = new URL(request.url); 30 | const supabase = await createClient(); 31 | 32 | const { data, error } = await supabase.auth.signInWithOAuth({ 33 | provider: "github", 34 | options: { 35 | redirectTo: `${origin}/api/auth/callback?provider=github`, 36 | }, 37 | }); 38 | 39 | if (error) { 40 | console.error("GitHub OAuth error:", error); 41 | return NextResponse.json({ error: error.message }, { status: 400 }); 42 | } 43 | 44 | return NextResponse.json({ url: data?.url }); 45 | } catch (error) { 46 | return NextResponse.json( 47 | { error: "Internal server error" }, 48 | { status: 500 } 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/api/auth/google/signin/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 4 | import { TApiErrorResponse } from "@/types/api"; 5 | import { AUTH_CONFIG } from "@/config/auth"; 6 | 7 | export async function POST(request: NextRequest) { 8 | try { 9 | // Check if Google auth is enabled in the config 10 | if (!AUTH_CONFIG.socialProviders.google.enabled) { 11 | return NextResponse.json( 12 | { error: "Google authentication is disabled" }, 13 | { status: 403 } 14 | ) satisfies NextResponse; 15 | } 16 | 17 | if (authRateLimit) { 18 | const ip = getClientIp(request); 19 | const { success } = await authRateLimit.limit(ip); 20 | 21 | if (!success) { 22 | return NextResponse.json( 23 | { error: "Too many requests. Please try again later." }, 24 | { status: 429 } 25 | ) satisfies NextResponse; 26 | } 27 | } 28 | 29 | const { origin } = new URL(request.url); 30 | const supabase = await createClient(); 31 | 32 | const { data, error } = await supabase.auth.signInWithOAuth({ 33 | provider: "google", 34 | options: { 35 | redirectTo: `${origin}/api/auth/callback?provider=google`, 36 | queryParams: { 37 | access_type: "offline", 38 | prompt: "consent", 39 | }, 40 | }, 41 | }); 42 | 43 | if (error) { 44 | console.error("Google OAuth error:", error); 45 | return NextResponse.json({ error: error.message }, { status: 400 }); 46 | } 47 | 48 | return NextResponse.json({ url: data?.url }); 49 | } catch (error) { 50 | return NextResponse.json( 51 | { error: "Internal server error" }, 52 | { status: 500 } 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app/api/auth/logout/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { basicRateLimit, getClientIp } from "@/utils/rate-limit"; 4 | import { TApiErrorResponse } from "@/types/api"; 5 | import { getCurrentDeviceSessionId } from "@/utils/auth/device-sessions"; 6 | 7 | export async function POST(request: NextRequest) { 8 | try { 9 | const { searchParams } = new URL(request.url); 10 | const next = searchParams.get("next"); 11 | const message = searchParams.get("message") || "You have been logged out"; 12 | 13 | if (basicRateLimit) { 14 | const ip = getClientIp(request); 15 | const { success } = await basicRateLimit.limit(ip); 16 | 17 | if (!success) { 18 | return NextResponse.json( 19 | { error: "Too many requests. Please try again later." }, 20 | { status: 429 } 21 | ) satisfies NextResponse; 22 | } 23 | } 24 | 25 | const supabase = await createClient(); 26 | const adminClient = await createClient({ useServiceRole: true }); 27 | 28 | // Clear Supabase session 29 | await supabase.auth.signOut({ scope: "local" }); 30 | 31 | console.log("Cleared Supabase session"); 32 | 33 | // Create response - either redirect or success JSON 34 | const response = next 35 | ? NextResponse.redirect( 36 | `${new URL(request.url).origin}${next}?message=${encodeURIComponent(message)}` 37 | ) 38 | : NextResponse.json({ success: true }); 39 | 40 | // Get device session ID using our utility 41 | const deviceSessionId = getCurrentDeviceSessionId(request); 42 | 43 | // Clear device session cookie 44 | response.cookies.delete("device_session_id"); 45 | 46 | // If we had a device session, delete it from DB 47 | if (deviceSessionId) { 48 | await adminClient 49 | .from("device_sessions") 50 | .delete() 51 | .eq("id", deviceSessionId); 52 | } 53 | 54 | return response; 55 | } catch (error) { 56 | return NextResponse.json( 57 | { error: "An error occurred while logging out." }, 58 | { status: 500 } 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/api/auth/user/events/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { createClient } from "@/utils/supabase/server"; 3 | import { TApiErrorResponse, TGetEventsResponse } from "@/types/api"; 4 | import { apiRateLimit, getClientIp } from "@/utils/rate-limit"; 5 | import { getUser } from "@/utils/auth"; 6 | import { TEventType } from "@/types/auth"; 7 | 8 | const DEFAULT_PAGE_SIZE = 20; 9 | const MAX_PAGE_SIZE = 100; 10 | 11 | export async function GET(request: NextRequest) { 12 | try { 13 | if (apiRateLimit) { 14 | const ip = getClientIp(request); 15 | const { success } = await apiRateLimit.limit(ip); 16 | if (!success) { 17 | return NextResponse.json( 18 | { error: "Too many requests. Please try again later." }, 19 | { status: 429 } 20 | ) satisfies NextResponse; 21 | } 22 | } 23 | 24 | const supabase = await createClient(); 25 | const { user, error } = await getUser({ supabase }); 26 | if (error || !user) { 27 | return NextResponse.json( 28 | { error: "Unauthorized" }, 29 | { status: 401 } 30 | ) satisfies NextResponse; 31 | } 32 | 33 | // Get query params 34 | const searchParams = request.nextUrl.searchParams; 35 | const cursor = searchParams.get("cursor"); // For pagination 36 | const limit = Math.min( 37 | parseInt(searchParams.get("limit") || String(DEFAULT_PAGE_SIZE)), 38 | MAX_PAGE_SIZE 39 | ); 40 | const types = searchParams.getAll("type") as TEventType[]; // Filter by event types 41 | const from = searchParams.get("from"); // From date 42 | const to = searchParams.get("to"); // To date 43 | 44 | // Build query 45 | let query = supabase 46 | .from("account_events") 47 | .select("*", { count: "exact" }) 48 | .eq("user_id", user.id) 49 | .order("created_at", { ascending: false }); 50 | 51 | // Apply filters if provided 52 | if (types.length > 0) { 53 | query = query.in("event_type", types); 54 | } 55 | if (from) { 56 | query = query.gte("created_at", from); 57 | } 58 | if (to) { 59 | query = query.lte("created_at", to); 60 | } 61 | if (cursor) { 62 | query = query.lt("created_at", cursor); 63 | } 64 | 65 | // Get one more than requested to check if there are more pages 66 | const { 67 | data: events, 68 | error: fetchError, 69 | count, 70 | } = await query.limit(limit + 1); 71 | 72 | if (fetchError) { 73 | console.error("Failed to fetch events:", fetchError); 74 | return NextResponse.json( 75 | { error: "Failed to fetch events" }, 76 | { status: 500 } 77 | ) satisfies NextResponse; 78 | } 79 | 80 | // Check if there are more pages 81 | const hasMore = events ? events.length > limit : false; 82 | // Remove the extra item we fetched 83 | if (hasMore && events) { 84 | events.pop(); 85 | } 86 | 87 | return NextResponse.json({ 88 | events, 89 | hasMore, 90 | total: count || 0, 91 | }) satisfies NextResponse; 92 | } catch (error) { 93 | console.error("Error fetching events:", error); 94 | return NextResponse.json( 95 | { error: "An unexpected error occurred" }, 96 | { status: 500 } 97 | ) satisfies NextResponse; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app/api/auth/user/update/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { createClient } from "@/utils/supabase/server"; 3 | import { profileUpdateSchema } from "@/validation/auth-validation"; 4 | import { 5 | TApiErrorResponse, 6 | TEmptySuccessResponse, 7 | TUpdateUserRequest, 8 | } from "@/types/api"; 9 | import { apiRateLimit, getClientIp } from "@/utils/rate-limit"; 10 | import { getUser } from "@/utils/auth"; 11 | import { getCurrentDeviceSession } from "@/utils/auth/device-sessions/server"; 12 | import { logAccountEvent } from "@/utils/account-events/server"; 13 | 14 | export async function POST(request: NextRequest) { 15 | try { 16 | if (apiRateLimit) { 17 | const ip = getClientIp(request); 18 | const { success } = await apiRateLimit.limit(ip); 19 | 20 | if (!success) { 21 | return NextResponse.json( 22 | { error: "Too many requests. Please try again later." }, 23 | { status: 429 } 24 | ) satisfies NextResponse; 25 | } 26 | } 27 | 28 | const supabase = await createClient(); 29 | const { user, error } = await getUser({ supabase }); 30 | if (error || !user) { 31 | return NextResponse.json( 32 | { error: "Unauthorized" }, 33 | { status: 401 } 34 | ) satisfies NextResponse; 35 | } 36 | 37 | const { 38 | deviceSession, 39 | isValid, 40 | error: sessionError, 41 | } = await getCurrentDeviceSession({ request, supabase, user }); 42 | 43 | if (!isValid || !deviceSession) { 44 | console.error("Invalid device session for user update", { 45 | userId: user.id, 46 | error: sessionError?.message, 47 | }); 48 | return NextResponse.json( 49 | { error: sessionError?.message || "Invalid or expired device session" }, 50 | { status: 401 } 51 | ) satisfies NextResponse; 52 | } 53 | 54 | const body = await request.json(); 55 | const validation = profileUpdateSchema.safeParse(body) as { 56 | success: boolean; 57 | data: TUpdateUserRequest; 58 | error?: any; 59 | }; 60 | 61 | if (!validation.success) { 62 | return NextResponse.json( 63 | { error: validation.error.issues[0]?.message || "Invalid input" }, 64 | { status: 400 } 65 | ) satisfies NextResponse; 66 | } 67 | 68 | const updateData = validation.data.data; 69 | 70 | if (updateData.email) { 71 | return NextResponse.json( 72 | { 73 | error: "Email updates must be done through the change-email endpoint", 74 | }, 75 | { status: 400 } 76 | ) satisfies NextResponse; 77 | } 78 | 79 | const { error: updateError } = await supabase 80 | .from("users") 81 | .update({ 82 | ...updateData, 83 | updated_at: new Date().toISOString(), 84 | }) 85 | .eq("id", user.id); 86 | 87 | if (updateError) { 88 | console.error("Failed to update user profile:", updateError); 89 | return NextResponse.json( 90 | { error: "Failed to update profile" }, 91 | { status: 500 } 92 | ) satisfies NextResponse; 93 | } 94 | 95 | await logAccountEvent({ 96 | user_id: user.id, 97 | event_type: "PROFILE_UPDATED", 98 | device_session_id: deviceSession.id, 99 | metadata: { 100 | fields: Object.keys(updateData), 101 | category: "info", 102 | description: `Profile updated: ${Object.keys(updateData).join(", ")}`, 103 | }, 104 | }); 105 | 106 | return NextResponse.json({}) satisfies NextResponse; 107 | } catch (error) { 108 | console.error("Error updating user profile:", error); 109 | return NextResponse.json( 110 | { error: "An unexpected error occurred" }, 111 | { status: 500 } 112 | ) satisfies NextResponse; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/app/api/auth/verify-device/send-code/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { TApiErrorResponse, TEmptySuccessResponse } from "@/types/api"; 4 | import { Resend } from "resend"; 5 | import DeviceVerificationEmail from "@emails/templates/device-verification"; 6 | import type { TDeviceSession, TUser } from "@/types/auth"; 7 | import { AUTH_CONFIG } from "@/config/auth"; 8 | import { authRateLimit, getClientIp } from "@/utils/rate-limit"; 9 | import { generateVerificationCode } from "@/utils/auth/verification-codes"; 10 | 11 | export async function POST(request: NextRequest) { 12 | if (authRateLimit) { 13 | const ip = getClientIp(request); 14 | const { success } = await authRateLimit.limit(ip); 15 | 16 | if (!success) { 17 | return NextResponse.json( 18 | { 19 | error: "Too many requests. Please try again later.", 20 | }, 21 | { status: 429 } 22 | ) satisfies NextResponse; 23 | } 24 | } 25 | 26 | if (!process.env.RESEND_API_KEY) { 27 | return NextResponse.json( 28 | { error: "Resend is not configured" }, 29 | { status: 500 } 30 | ) satisfies NextResponse; 31 | } 32 | 33 | try { 34 | const { device_session_id, device_name } = await request.json(); 35 | 36 | if (!device_session_id || !device_name) { 37 | return NextResponse.json( 38 | { error: "Missing required fields" }, 39 | { status: 400 } 40 | ) satisfies NextResponse; 41 | } 42 | 43 | const supabase = await createClient(); 44 | const adminClient = await createClient({ useServiceRole: true }); 45 | 46 | // Get device session to verify ownership and get user email 47 | const { data: deviceSession, error: sessionError } = await supabase 48 | .from("device_sessions") 49 | .select( 50 | ` 51 | user_id, 52 | user:users!inner ( 53 | email 54 | ) 55 | ` 56 | ) 57 | .eq("id", device_session_id) 58 | .single }>(); 59 | 60 | if (sessionError || !deviceSession?.user?.email) { 61 | return NextResponse.json( 62 | { error: "Invalid session" }, 63 | { status: 404 } 64 | ) satisfies NextResponse; 65 | } 66 | 67 | const { code, hash, salt } = await generateVerificationCode({ 68 | format: "numeric", 69 | alphanumericLength: AUTH_CONFIG.deviceVerification.codeLength, 70 | }); 71 | 72 | const expiresAt = new Date(); 73 | expiresAt.setMinutes( 74 | expiresAt.getMinutes() + AUTH_CONFIG.deviceVerification.codeExpirationTime 75 | ); 76 | 77 | // Store verification code 78 | const { error: insertError } = await adminClient 79 | .from("verification_codes") 80 | .insert({ 81 | device_session_id, 82 | code_hash: hash, 83 | salt, 84 | expires_at: expiresAt.toISOString(), 85 | }); 86 | 87 | if (insertError) { 88 | console.error("Failed to store verification code:", insertError); 89 | return NextResponse.json( 90 | { error: "Failed to generate verification code" }, 91 | { status: 500 } 92 | ) satisfies NextResponse; 93 | } 94 | 95 | // Send email with code 96 | const resend = new Resend(process.env.RESEND_API_KEY); 97 | await resend.emails.send({ 98 | from: process.env.RESEND_FROM_EMAIL!, 99 | to: deviceSession.user.email, 100 | subject: "Verify your device", 101 | react: DeviceVerificationEmail({ 102 | code, 103 | device_name, 104 | expires_in: `${AUTH_CONFIG.deviceVerification.codeExpirationTime} minutes`, 105 | }), 106 | }); 107 | 108 | return NextResponse.json( 109 | {}, 110 | { status: 200 } 111 | ) satisfies NextResponse; 112 | } catch (error) { 113 | console.error("Error sending verification code:", error); 114 | return NextResponse.json( 115 | { error: "Failed to send verification code" }, 116 | { status: 500 } 117 | ) satisfies NextResponse; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useSearchParams } from "next/navigation"; 6 | import { useState } from "react"; 7 | import { ChevronRight } from "lucide-react"; 8 | import { 9 | Card, 10 | CardHeader, 11 | CardTitle, 12 | CardDescription, 13 | CardContent, 14 | CardFooter, 15 | } from "@/components/ui/card"; 16 | import { Suspense } from "react"; 17 | 18 | interface TErrorAction { 19 | label: string; 20 | href: string; 21 | type?: "default" | "secondary"; 22 | } 23 | 24 | function ErrorContent() { 25 | const searchParams = useSearchParams(); 26 | const errorTitle = searchParams.get("title") || "Something went wrong"; 27 | const errorMessage = 28 | searchParams.get("message") || "An unexpected error occurred"; 29 | const errorActions = searchParams.get("actions") 30 | ? JSON.parse(decodeURIComponent(searchParams.get("actions") || "[]")) 31 | : [{ label: "Go Home", href: "/", type: "default" }]; 32 | const errorObject = searchParams.get("error"); 33 | const [showDetails, setShowDetails] = useState(false); 34 | 35 | return ( 36 |
37 | 38 | 39 | {errorTitle} 40 | 41 | {errorMessage} 42 | 43 | 44 | 45 |
46 | {errorActions.map((action: TErrorAction) => ( 47 | 48 | 54 | 55 | ))} 56 |
57 |
58 | {errorObject && ( 59 | 60 | <> 61 | 70 | {showDetails && ( 71 |
 72 |                   
 73 |                     {JSON.stringify(
 74 |                       {
 75 |                         error: errorObject,
 76 |                       },
 77 |                       null,
 78 |                       2
 79 |                     )}
 80 |                   
 81 |                 
82 | )} 83 | 84 |
85 | )} 86 |
87 |
88 | ); 89 | } 90 | 91 | export default function ErrorPage() { 92 | return ( 93 | 96 | 97 | 98 | Loading... 99 | 100 | 101 | 102 | } 103 | > 104 | 105 | 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/app/auth/login-help/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Header } from "@/components/header"; 4 | import { Button } from "@/components/ui/button"; 5 | import Link from "next/link"; 6 | import { FaKey } from "react-icons/fa"; 7 | import { useSearchParams } from "next/navigation"; 8 | 9 | interface HelpOption { 10 | icon: React.ReactNode; 11 | title: string; 12 | description: string; 13 | href: string; 14 | } 15 | 16 | export default function LoginHelp() { 17 | const searchParams = useSearchParams(); 18 | const email = searchParams.get("email"); 19 | 20 | const helpOptions: HelpOption[] = [ 21 | { 22 | icon: , 23 | title: "Reset your password", 24 | description: "We'll send you a link to create a new password", 25 | href: `/auth/forgot-password${email ? `?email=${encodeURIComponent(email)}` : ""}`, 26 | }, 27 | ]; 28 | 29 | return ( 30 |
31 |
32 |
33 |
34 |
35 |
36 |

Need help logging in?

37 |

38 | We'll make it easy. What do you need help with? 39 |

40 |
41 |
42 |
43 | {helpOptions.map((option) => ( 44 | 45 | 59 | 60 | ))} 61 |
62 |
63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { AuthForm } from "@/components/auth-form"; 2 | 3 | export default async function Login({ 4 | searchParams, 5 | }: { 6 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 7 | }) { 8 | const params = await searchParams; 9 | 10 | const email = params.email 11 | ? decodeURIComponent(params.email.toString()) 12 | : null; 13 | 14 | // Parse the available methods from the URL 15 | const initialMethods = params.available_methods 16 | ? JSON.parse(decodeURIComponent(params.available_methods.toString())) 17 | : []; 18 | 19 | return ( 20 |
21 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/auth/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { AuthForm } from "@/components/auth-form"; 2 | 3 | export default async function Signup({ 4 | searchParams, 5 | }: { 6 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 7 | }) { 8 | const params = await searchParams; 9 | 10 | return ( 11 |
12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from "@/components/header"; 2 | 3 | export default function Dashboard() { 4 | return ( 5 |
6 |
7 |
8 |

Dashboard

9 |

10 | This is the dashboard. Do whatever you need here! 11 |

12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazeway-dev/Mazeway/a019adb73fc281a8ff463cba3352c5c617c3c647/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazeway-dev/Mazeway/a019adb73fc281a8ff463cba3352c5c617c3c647/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazeway-dev/Mazeway/a019adb73fc281a8ff463cba3352c5c617c3c647/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 0 0% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 0 0% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 0 0% 3.9%; 17 | --primary: 0 0% 9%; 18 | --primary-foreground: 0 0% 98%; 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | --muted: 0 0% 96.1%; 22 | --muted-foreground: 0 0% 45.1%; 23 | --accent: 0 0% 96.1%; 24 | --accent-foreground: 0 0% 9%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 0 0% 98%; 27 | --border: 0 0% 89.8%; 28 | --input: 0 0% 89.8%; 29 | --ring: 0 0% 3.9%; 30 | --chart-1: 12 76% 61%; 31 | --chart-2: 173 58% 39%; 32 | --chart-3: 197 37% 24%; 33 | --chart-4: 43 74% 66%; 34 | --chart-5: 27 87% 67%; 35 | --radius: 0.6rem; 36 | --sidebar-background: 0 0% 100%; 37 | --sidebar-foreground: 0 0% 3.9%; 38 | --sidebar-primary: 0 0% 9%; 39 | --sidebar-primary-foreground: 0 0% 98%; 40 | --sidebar-accent: 0 0% 96.1%; 41 | --sidebar-accent-foreground: 0 0% 9%; 42 | --sidebar-border: 0 0% 89.8%; 43 | --sidebar-ring: 0 0% 3.9%; 44 | } 45 | .dark { 46 | --background: 0 0% 3.9%; 47 | --foreground: 0 0% 98%; 48 | --card: 0 0% 3.9%; 49 | --card-foreground: 0 0% 98%; 50 | --popover: 0 0% 3.9%; 51 | --popover-foreground: 0 0% 98%; 52 | --primary: 0 0% 98%; 53 | --primary-foreground: 0 0% 9%; 54 | --secondary: 0 0% 14.9%; 55 | --secondary-foreground: 0 0% 98%; 56 | --muted: 0 0% 14.9%; 57 | --muted-foreground: 0 0% 63.9%; 58 | --accent: 0 0% 14.9%; 59 | --accent-foreground: 0 0% 98%; 60 | --destructive: 0 100% 60%; 61 | --destructive-foreground: 0 0% 98%; 62 | --border: 0 0% 14.9%; 63 | --input: 0 0% 14.9%; 64 | --ring: 0 0% 83.1%; 65 | --chart-1: 220 70% 50%; 66 | --chart-2: 160 60% 45%; 67 | --chart-3: 30 80% 55%; 68 | --chart-4: 280 65% 60%; 69 | --chart-5: 340 75% 55%; 70 | --sidebar-background: 0 0% 3.9%; 71 | --sidebar-foreground: 0 0% 98%; 72 | --sidebar-primary: 0 0% 98%; 73 | --sidebar-primary-foreground: 0 0% 9%; 74 | --sidebar-accent: 0 0% 14.9%; 75 | --sidebar-accent-foreground: 0 0% 98%; 76 | --sidebar-border: 0 0% 14.9%; 77 | --sidebar-ring: 0 0% 83.1%; 78 | } 79 | } 80 | 81 | @layer base { 82 | * { 83 | @apply border-border; 84 | } 85 | body { 86 | @apply bg-background text-foreground; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import { ThemeProvider } from "next-themes"; 4 | import { Analytics } from "@vercel/analytics/react"; 5 | import "./globals.css"; 6 | import { Toaster } from "@/components/ui/sonner"; 7 | import { UserProvider } from "@/components/user-provider"; 8 | import { TooltipProvider } from "@/components/ui/tooltip"; 9 | import { URLErrorHandler } from "@/components/url-error-handler"; 10 | 11 | const geistSans = localFont({ 12 | src: "./fonts/GeistVF.woff", 13 | variable: "--font-geist-sans", 14 | weight: "100 900", 15 | }); 16 | const geistMono = localFont({ 17 | src: "./fonts/GeistMonoVF.woff", 18 | variable: "--font-geist-mono", 19 | weight: "100 900", 20 | }); 21 | 22 | export const metadata: Metadata = { 23 | title: "Create Next App", 24 | description: "Generated by create next app", 25 | }; 26 | 27 | export default function RootLayout({ 28 | children, 29 | }: Readonly<{ 30 | children: React.ReactNode; 31 | }>) { 32 | return ( 33 | 34 | 37 | 38 | 39 | 40 | {children} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from "@/components/header"; 2 | import { Badge } from "@/components/ui/badge"; 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | 6 | export default function Home() { 7 | return ( 8 |
9 |
10 |
11 |
12 | 13 | Demo 14 | 15 |

16 | Mazeway Demo 17 |

18 |

19 | Drop in production-ready auth code with everything apps need 20 |

21 |
22 |
23 | 24 | 27 | 28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/auth-confirm.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { X } from "lucide-react"; 3 | import { Button } from "./ui/button"; 4 | import { toast } from "sonner"; 5 | import { api } from "@/utils/api"; 6 | 7 | interface ConfirmProps { 8 | email: string; 9 | show: boolean; 10 | onClose: () => void; 11 | } 12 | 13 | export function Confirm({ email, show, onClose }: ConfirmProps) { 14 | const [timeLeft, setTimeLeft] = useState(10); 15 | const [isResending, setIsResending] = useState(false); 16 | 17 | useEffect(() => { 18 | if (!show) return; 19 | 20 | const timer = setInterval(() => { 21 | setTimeLeft((current) => { 22 | if (current <= 1) { 23 | clearInterval(timer); 24 | return 0; 25 | } 26 | return current - 1; 27 | }); 28 | }, 1000); 29 | 30 | return () => clearInterval(timer); 31 | }, [show]); 32 | 33 | async function handleResend() { 34 | if (!email) return; 35 | 36 | setIsResending(true); 37 | try { 38 | await api.auth.resendConfirmation(email); 39 | 40 | toast.success("Email sent", { 41 | description: "Check your inbox for the confirmation link", 42 | duration: 3000, 43 | }); 44 | setTimeLeft(10); 45 | } catch (error) { 46 | toast.error("Error resending email", { 47 | description: 48 | error instanceof Error 49 | ? error.message 50 | : "An unexpected error occurred", 51 | duration: 3000, 52 | }); 53 | } finally { 54 | setIsResending(false); 55 | } 56 | } 57 | 58 | return ( 59 |
64 |
69 |

Confirm your email

70 |

71 | {email ? ( 72 | <> 73 | We have sent a confirmation email to{" "} 74 | {email}. Click the 75 | link inside to finish setting up your account. 76 | 77 | ) : ( 78 | "No email was provided. Please try again." 79 | )} 80 |

81 | {email && ( 82 | 98 | )} 99 |
100 | 106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/components/back-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useRouter } from "next/navigation"; 5 | import { ChevronLeft } from "lucide-react"; 6 | 7 | export function BackButton({ onClick }: { onClick?: () => void }) { 8 | const router = useRouter(); 9 | 10 | return ( 11 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import Image from "next/image"; 5 | import { Button } from "./ui/button"; 6 | import { UserDropdown } from "./user-dropdown"; 7 | 8 | interface HeaderProps { 9 | isInitiallyLoggedIn: boolean; 10 | sidebar?: React.ReactNode; 11 | } 12 | 13 | export function Header({ isInitiallyLoggedIn, sidebar }: HeaderProps) { 14 | return ( 15 |
16 |
17 | {sidebar} 18 | 19 | Logo 27 | 28 |
29 |
30 | {isInitiallyLoggedIn ? ( 31 | 32 | ) : ( 33 | 34 | 35 | 36 | )} 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/revoke-all-devices.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogDescription, 8 | DialogHeader, 9 | DialogTitle, 10 | } from "@/components/ui/dialog"; 11 | import { VerifyForm } from "@/components/verify-form"; 12 | import { TVerificationFactor } from "@/types/auth"; 13 | import { api } from "@/utils/api"; 14 | import { useState } from "react"; 15 | import { toast } from "sonner"; 16 | import { useDeviceSessions } from "@/hooks/use-device-sessions"; 17 | 18 | export function LogoutAllDevices() { 19 | const { refresh } = useDeviceSessions(); 20 | const [needsVerification, setNeedsVerification] = useState(false); 21 | const [verificationData, setVerificationData] = useState<{ 22 | availableMethods: TVerificationFactor[]; 23 | } | null>(null); 24 | const [isLoading, setIsLoading] = useState(false); 25 | 26 | const handleLogoutAllDevices = async () => { 27 | try { 28 | setIsLoading(true); 29 | 30 | // Attempt to revoke all sessions 31 | const data = await api.auth.device.revokeSession({ 32 | revokeAll: true, 33 | }); 34 | 35 | // If verification is needed, show dialog 36 | if ( 37 | data.requiresVerification && 38 | data.availableMethods && 39 | data.availableMethods.length > 0 40 | ) { 41 | setVerificationData({ availableMethods: data.availableMethods }); 42 | setNeedsVerification(true); 43 | return; 44 | } 45 | 46 | // Verification completed/not needed -> proceed with logout 47 | toast.success("Successfully logged out all other devices"); 48 | refresh(); 49 | 50 | setNeedsVerification(false); 51 | setVerificationData(null); 52 | } catch (error) { 53 | console.error("Error logging out all devices", error); 54 | toast.error("Error", { 55 | description: 56 | error instanceof Error ? error.message : "An error occurred", 57 | duration: 3000, 58 | }); 59 | } finally { 60 | setIsLoading(false); 61 | } 62 | }; 63 | 64 | return ( 65 | <> 66 | 74 | 75 | {verificationData && verificationData.availableMethods && ( 76 | 77 | 78 | 79 | Verify your identity 80 | 81 | To logout all other devices, please verify your identity 82 | 83 | 84 | handleLogoutAllDevices()} 87 | /> 88 | 89 | 90 | )} 91 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 56 | 57 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 58 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /src/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>