From 5eb44473f0d1f5129d1a6eb051e046ce42cf9841 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 Aug 2025 08:31:00 +0000 Subject: [PATCH 1/3] Initial plan From 6bc4e82dd3223300611692f0399a2aae492305cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 Aug 2025 08:50:43 +0000 Subject: [PATCH 2/3] Add authentication backend implementation with types, entities, and API routes Co-authored-by: tikazyq <3393101+tikazyq@users.noreply.github.com> --- apps/web/app/api/auth/login/route.ts | 55 ++ apps/web/app/api/auth/me/route.ts | 13 + apps/web/app/api/auth/refresh/route.ts | 44 ++ apps/web/app/api/auth/register/route.ts | 58 ++ apps/web/app/api/auth/reset-password/route.ts | 87 +++ apps/web/app/api/auth/verify-email/route.ts | 53 ++ apps/web/lib/auth-middleware.ts | 92 +++ apps/web/next.config.js | 10 + packages/core/package.json | 8 + packages/core/src/auth.ts | 5 + packages/core/src/entities/index.ts | 1 + packages/core/src/entities/user.entity.ts | 262 +++++++++ packages/core/src/services/auth-service.ts | 548 ++++++++++++++++++ packages/core/src/services/index.ts | 1 + packages/core/src/types/auth.ts | 138 +++++ packages/core/src/types/index.ts | 3 + pnpm-lock.yaml | 373 ++++++++++++ pnpm-workspace.yaml | 1 + 18 files changed, 1752 insertions(+) create mode 100644 apps/web/app/api/auth/login/route.ts create mode 100644 apps/web/app/api/auth/me/route.ts create mode 100644 apps/web/app/api/auth/refresh/route.ts create mode 100644 apps/web/app/api/auth/register/route.ts create mode 100644 apps/web/app/api/auth/reset-password/route.ts create mode 100644 apps/web/app/api/auth/verify-email/route.ts create mode 100644 apps/web/lib/auth-middleware.ts create mode 100644 packages/core/src/auth.ts create mode 100644 packages/core/src/entities/user.entity.ts create mode 100644 packages/core/src/services/auth-service.ts create mode 100644 packages/core/src/types/auth.ts diff --git a/apps/web/app/api/auth/login/route.ts b/apps/web/app/api/auth/login/route.ts new file mode 100644 index 00000000..cf484cc7 --- /dev/null +++ b/apps/web/app/api/auth/login/route.ts @@ -0,0 +1,55 @@ +/** + * User login endpoint + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +const loginSchema = z.object({ + email: z.string().email('Invalid email format'), + password: z.string().min(1, 'Password is required'), +}); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const validatedData = loginSchema.parse(body); + + // Dynamic import to keep server-only + const { AuthService } = await import('@codervisor/devlog-core/auth'); + const authService = AuthService.getInstance(); + const result = await authService.login(validatedData); + + return NextResponse.json({ + success: true, + message: 'Login successful', + user: result.user, + tokens: result.tokens, + }, { status: 200 }); + + } catch (error) { + console.error('Login error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json({ + success: false, + error: 'Validation error', + details: error.errors, + }, { status: 400 }); + } + + if (error instanceof Error) { + if (error.message.includes('Invalid email or password')) { + return NextResponse.json({ + success: false, + error: 'Invalid email or password', + }, { status: 401 }); + } + } + + return NextResponse.json({ + success: false, + error: 'Login failed', + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/web/app/api/auth/me/route.ts b/apps/web/app/api/auth/me/route.ts new file mode 100644 index 00000000..2b1830a4 --- /dev/null +++ b/apps/web/app/api/auth/me/route.ts @@ -0,0 +1,13 @@ +/** + * Get current user information endpoint + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth-middleware'; + +export const GET = withAuth(async (req) => { + return NextResponse.json({ + success: true, + user: req.user, + }, { status: 200 }); +}); \ No newline at end of file diff --git a/apps/web/app/api/auth/refresh/route.ts b/apps/web/app/api/auth/refresh/route.ts new file mode 100644 index 00000000..aaf6b31b --- /dev/null +++ b/apps/web/app/api/auth/refresh/route.ts @@ -0,0 +1,44 @@ +/** + * Token refresh endpoint + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +const refreshSchema = z.object({ + refreshToken: z.string().min(1, 'Refresh token is required'), +}); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const validatedData = refreshSchema.parse(body); + + // Dynamic import to keep server-only + const { AuthService } = await import('@codervisor/devlog-core/auth'); + const authService = AuthService.getInstance(); + const newTokens = await authService.refreshToken(validatedData.refreshToken); + + return NextResponse.json({ + success: true, + message: 'Token refreshed successfully', + tokens: newTokens, + }, { status: 200 }); + + } catch (error) { + console.error('Token refresh error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json({ + success: false, + error: 'Validation error', + details: error.errors, + }, { status: 400 }); + } + + return NextResponse.json({ + success: false, + error: 'Invalid or expired refresh token', + }, { status: 401 }); + } +} \ No newline at end of file diff --git a/apps/web/app/api/auth/register/route.ts b/apps/web/app/api/auth/register/route.ts new file mode 100644 index 00000000..b47e1bc9 --- /dev/null +++ b/apps/web/app/api/auth/register/route.ts @@ -0,0 +1,58 @@ +/** + * User registration endpoint + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +const registrationSchema = z.object({ + email: z.string().email('Invalid email format'), + password: z.string().min(8, 'Password must be at least 8 characters'), + name: z.string().optional(), +}); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const validatedData = registrationSchema.parse(body); + + // Dynamic import to keep server-only + const { AuthService } = await import('@codervisor/devlog-core/auth'); + const authService = AuthService.getInstance(); + const result = await authService.register(validatedData); + + // TODO: Send email verification email with result.emailToken + // For now, we'll just return success + + return NextResponse.json({ + success: true, + message: 'Registration successful. Please check your email for verification.', + user: result.user, + }, { status: 201 }); + + } catch (error) { + console.error('Registration error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json({ + success: false, + error: 'Validation error', + details: error.errors, + }, { status: 400 }); + } + + if (error instanceof Error) { + if (error.message.includes('already exists')) { + return NextResponse.json({ + success: false, + error: 'User with this email already exists', + }, { status: 409 }); + } + } + + return NextResponse.json({ + success: false, + error: 'Registration failed', + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/web/app/api/auth/reset-password/route.ts b/apps/web/app/api/auth/reset-password/route.ts new file mode 100644 index 00000000..822d7fd9 --- /dev/null +++ b/apps/web/app/api/auth/reset-password/route.ts @@ -0,0 +1,87 @@ +/** + * Password reset endpoints + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +const requestResetSchema = z.object({ + email: z.string().email('Invalid email format'), +}); + +const confirmResetSchema = z.object({ + token: z.string().min(1, 'Reset token is required'), + newPassword: z.string().min(8, 'Password must be at least 8 characters'), +}); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { searchParams } = new URL(req.url); + const action = searchParams.get('action'); + + // Dynamic import to keep server-only + const { AuthService } = await import('@codervisor/devlog-core/auth'); + const authService = AuthService.getInstance(); + + if (action === 'request') { + const validatedData = requestResetSchema.parse(body); + + // Generate reset token (returns null if email doesn't exist, for security) + const resetToken = await authService.generatePasswordResetToken(validatedData.email); + + // TODO: Send password reset email with resetToken.token + // Always return success for security (don't reveal if email exists) + + return NextResponse.json({ + success: true, + message: 'If your email is registered, you will receive a password reset link.', + }, { status: 200 }); + + } else if (action === 'confirm') { + const validatedData = confirmResetSchema.parse(body); + + const user = await authService.resetPassword( + validatedData.token, + validatedData.newPassword + ); + + return NextResponse.json({ + success: true, + message: 'Password reset successfully', + user, + }, { status: 200 }); + + } else { + return NextResponse.json({ + success: false, + error: 'Invalid action. Use ?action=request or ?action=confirm', + }, { status: 400 }); + } + + } catch (error) { + console.error('Password reset error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json({ + success: false, + error: 'Validation error', + details: error.errors, + }, { status: 400 }); + } + + if (error instanceof Error) { + if (error.message.includes('Invalid or expired')) { + return NextResponse.json({ + success: false, + error: 'Invalid or expired reset token', + }, { status: 400 }); + } + } + + return NextResponse.json({ + success: false, + error: 'Password reset failed', + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/web/app/api/auth/verify-email/route.ts b/apps/web/app/api/auth/verify-email/route.ts new file mode 100644 index 00000000..293d2be3 --- /dev/null +++ b/apps/web/app/api/auth/verify-email/route.ts @@ -0,0 +1,53 @@ +/** + * Email verification endpoint + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +const verifyEmailSchema = z.object({ + token: z.string().min(1, 'Verification token is required'), +}); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const validatedData = verifyEmailSchema.parse(body); + + // Dynamic import to keep server-only + const { AuthService } = await import('@codervisor/devlog-core/auth'); + const authService = AuthService.getInstance(); + const user = await authService.verifyEmail(validatedData.token); + + return NextResponse.json({ + success: true, + message: 'Email verified successfully', + user, + }, { status: 200 }); + + } catch (error) { + console.error('Email verification error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json({ + success: false, + error: 'Validation error', + details: error.errors, + }, { status: 400 }); + } + + if (error instanceof Error) { + if (error.message.includes('Invalid or expired')) { + return NextResponse.json({ + success: false, + error: 'Invalid or expired verification token', + }, { status: 400 }); + } + } + + return NextResponse.json({ + success: false, + error: 'Email verification failed', + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/web/lib/auth-middleware.ts b/apps/web/lib/auth-middleware.ts new file mode 100644 index 00000000..ed417dfd --- /dev/null +++ b/apps/web/lib/auth-middleware.ts @@ -0,0 +1,92 @@ +/** + * Authentication middleware for API routes + */ + +import { NextRequest } from 'next/server'; +import type { SessionUser } from '@codervisor/devlog-core'; + +export interface AuthenticatedRequest extends NextRequest { + user: SessionUser; +} + +/** + * Middleware to verify JWT tokens and extract user information + */ +export async function withAuth( + handler: (req: AuthenticatedRequest, ...args: T[]) => Promise, +) { + return async (req: NextRequest, ...args: T[]) => { + try { + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return new Response(JSON.stringify({ error: 'Missing or invalid authorization header' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + // Import AuthService dynamically to avoid initialization issues + const { AuthService } = await import('@codervisor/devlog-core/auth'); + const authService = AuthService.getInstance(); + + const user = await authService.verifyToken(token); + + // Attach user to request + const authenticatedReq = req as AuthenticatedRequest; + authenticatedReq.user = user; + + return await handler(authenticatedReq, ...args); + } catch (error) { + return new Response(JSON.stringify({ error: 'Invalid or expired token' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + }; +} + +/** + * Optional authentication middleware - allows both authenticated and unauthenticated requests + */ +export async function withOptionalAuth( + handler: (req: NextRequest & { user?: SessionUser }, ...args: T[]) => Promise, +) { + return async (req: NextRequest, ...args: T[]) => { + try { + const authHeader = req.headers.get('authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + + const { AuthService } = await import('@codervisor/devlog-core/auth'); + const authService = AuthService.getInstance(); + + try { + const user = await authService.verifyToken(token); + (req as any).user = user; + } catch { + // Ignore token verification errors for optional auth + } + } + + return await handler(req as NextRequest & { user?: SessionUser }, ...args); + } catch (error) { + return await handler(req as NextRequest & { user?: SessionUser }, ...args); + } + }; +} + +/** + * Extract user from authenticated request + */ +export function getUser(req: AuthenticatedRequest): SessionUser { + return req.user; +} + +/** + * Check if user is authenticated + */ +export function isAuthenticated(req: NextRequest & { user?: SessionUser }): req is AuthenticatedRequest { + return !!(req as any).user; +} \ No newline at end of file diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 582e992a..643e2354 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -14,6 +14,9 @@ const nextConfig = { 'mysql2', 'better-sqlite3', 'reflect-metadata', + // Keep authentication dependencies server-side only + 'bcrypt', + 'jsonwebtoken', ], }, webpack: (config, { isServer }) => { @@ -26,6 +29,10 @@ const nextConfig = { /Module not found.*typeorm.*react-native/, /Module not found.*typeorm.*mysql/, /Module not found.*typeorm.*hana/, + // Bcrypt and authentication related warnings + /Module not found: Can't resolve 'mock-aws-s3'/, + /Module not found: Can't resolve 'aws-sdk'/, + /Module not found: Can't resolve 'nock'/, ]; // Handle the workspace packages properly @@ -56,6 +63,9 @@ const nextConfig = { mysql: false, 'better-sqlite3': false, 'reflect-metadata': false, + // Exclude authentication modules from client bundle + 'bcrypt': false, + 'jsonwebtoken': false, // Exclude problematic TypeORM drivers 'react-native-sqlite-storage': false, '@sap/hana-client': false, diff --git a/packages/core/package.json b/packages/core/package.json index 63e3fd49..2fd814fe 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,10 @@ "./server": { "types": "./build/server.d.ts", "import": "./build/server.js" + }, + "./auth": { + "types": "./build/auth.d.ts", + "import": "./build/auth.js" } }, "files": [ @@ -58,9 +62,11 @@ }, "license": "Apache-2.0", "dependencies": { + "bcrypt": "^5.1.1", "better-sqlite3": "^11.0.0", "cheerio": "1.1.2", "dotenv": "16.5.0", + "jsonwebtoken": "^9.0.2", "mysql2": "^3.11.0", "pg": "^8.12.0", "reflect-metadata": "0.2.2", @@ -68,7 +74,9 @@ "zod": "^3.22.4" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/better-sqlite3": "^7.6.0", + "@types/jsonwebtoken": "^9.0.7", "@types/node": "^20.0.0", "@types/pg": "^8.11.0", "@vitest/ui": "^2.1.9", diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 00000000..61b23563 --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,5 @@ +// Authentication-specific server exports +// These include bcrypt and JWT dependencies that should only be imported on the server +export { AuthService } from './services/auth-service.js'; +export * from './entities/user.entity.js'; +export * from './types/auth.js'; \ No newline at end of file diff --git a/packages/core/src/entities/index.ts b/packages/core/src/entities/index.ts index f447c573..133e4977 100644 --- a/packages/core/src/entities/index.ts +++ b/packages/core/src/entities/index.ts @@ -5,4 +5,5 @@ export * from './project.entity.js'; export * from './chat-session.entity.js'; export * from './chat-message.entity.js'; export * from './chat-devlog-link.entity.js'; +export * from './user.entity.js'; export * from './decorators.js'; diff --git a/packages/core/src/entities/user.entity.ts b/packages/core/src/entities/user.entity.ts new file mode 100644 index 00000000..14198962 --- /dev/null +++ b/packages/core/src/entities/user.entity.ts @@ -0,0 +1,262 @@ +/** + * User Entity for authentication and user management + */ + +import 'reflect-metadata'; +import { Column, CreateDateColumn, Entity, OneToMany, ManyToOne, JoinColumn, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import type { User } from '../types/index.js'; +import { getTimestampType, TimestampColumn } from './decorators.js'; + +@Entity('devlog_users') +export class UserEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'varchar', length: 255, unique: true }) + email!: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + name?: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + avatarUrl?: string; + + @Column({ type: 'varchar', length: 255 }) + passwordHash!: string; + + @Column({ type: 'boolean', default: false }) + isEmailVerified!: boolean; + + @CreateDateColumn({ + type: getTimestampType(), + name: 'created_at', + }) + createdAt!: Date; + + @UpdateDateColumn({ + type: getTimestampType(), + name: 'updated_at', + }) + updatedAt!: Date; + + @TimestampColumn({ name: 'last_login_at', nullable: true }) + lastLoginAt?: Date; + + @OneToMany(() => UserProviderEntity, provider => provider.user) + providers?: UserProviderEntity[]; + + /** + * Convert entity to User type (without password hash) + */ + toUser(): User { + return { + id: this.id, + email: this.email, + name: this.name, + avatarUrl: this.avatarUrl, + isEmailVerified: this.isEmailVerified, + createdAt: this.createdAt.toISOString(), + updatedAt: this.updatedAt.toISOString(), + lastLoginAt: this.lastLoginAt?.toISOString(), + }; + } + + /** + * Create entity from user registration data + */ + static fromRegistration( + registration: { email: string; name?: string; passwordHash: string }, + ): UserEntity { + const entity = new UserEntity(); + entity.email = registration.email; + entity.name = registration.name; + entity.passwordHash = registration.passwordHash; + entity.isEmailVerified = false; + return entity; + } + + /** + * Update entity with partial user data + */ + updateFromUserData(updates: Partial): void { + if (updates.name !== undefined) this.name = updates.name; + if (updates.avatarUrl !== undefined) this.avatarUrl = updates.avatarUrl; + if (updates.isEmailVerified !== undefined) this.isEmailVerified = updates.isEmailVerified; + this.updatedAt = new Date(); + } + + /** + * Update last login timestamp + */ + updateLastLogin(): void { + this.lastLoginAt = new Date(); + } +} + +@Entity('devlog_user_providers') +export class UserProviderEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'int' }) + userId!: number; + + @Column({ type: 'varchar', length: 50 }) + provider!: string; // 'github' | 'google' | 'wechat' + + @Column({ type: 'varchar', length: 255 }) + providerId!: string; + + @CreateDateColumn({ + type: getTimestampType(), + name: 'created_at', + }) + createdAt!: Date; + + @ManyToOne(() => UserEntity, user => user.providers) + @JoinColumn({ name: 'user_id' }) + user!: UserEntity; + + /** + * Convert entity to UserProvider type + */ + toUserProvider(): import('../types/index.js').UserProvider { + return { + id: this.id, + userId: this.userId, + provider: this.provider as import('../types/index.js').SSOProvider, + providerId: this.providerId, + createdAt: this.createdAt.toISOString(), + }; + } + + /** + * Create entity from SSO user info + */ + static fromSSOInfo( + userId: number, + ssoInfo: import('../types/index.js').SSOUserInfo, + ): UserProviderEntity { + const entity = new UserProviderEntity(); + entity.userId = userId; + entity.provider = ssoInfo.provider; + entity.providerId = ssoInfo.providerId; + return entity; + } +} + +@Entity('devlog_email_verification_tokens') +export class EmailVerificationTokenEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'int' }) + userId!: number; + + @Column({ type: 'varchar', length: 255, unique: true }) + token!: string; + + @TimestampColumn({ name: 'expires_at' }) + expiresAt!: Date; + + @CreateDateColumn({ + type: getTimestampType(), + name: 'created_at', + }) + createdAt!: Date; + + /** + * Convert entity to EmailVerificationToken type + */ + toEmailVerificationToken(): import('../types/index.js').EmailVerificationToken { + return { + id: this.id, + userId: this.userId, + token: this.token, + expiresAt: this.expiresAt.toISOString(), + createdAt: this.createdAt.toISOString(), + }; + } + + /** + * Create entity from token data + */ + static createToken(userId: number, token: string, expiresAt: Date): EmailVerificationTokenEntity { + const entity = new EmailVerificationTokenEntity(); + entity.userId = userId; + entity.token = token; + entity.expiresAt = expiresAt; + return entity; + } + + /** + * Check if token is expired + */ + isExpired(): boolean { + return new Date() > this.expiresAt; + } +} + +@Entity('devlog_password_reset_tokens') +export class PasswordResetTokenEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'int' }) + userId!: number; + + @Column({ type: 'varchar', length: 255, unique: true }) + token!: string; + + @TimestampColumn({ name: 'expires_at' }) + expiresAt!: Date; + + @CreateDateColumn({ + type: getTimestampType(), + name: 'created_at', + }) + createdAt!: Date; + + @Column({ type: 'boolean', default: false }) + used!: boolean; + + /** + * Convert entity to PasswordResetToken type + */ + toPasswordResetToken(): import('../types/index.js').PasswordResetToken { + return { + id: this.id, + userId: this.userId, + token: this.token, + expiresAt: this.expiresAt.toISOString(), + createdAt: this.createdAt.toISOString(), + used: this.used, + }; + } + + /** + * Create entity from token data + */ + static createToken(userId: number, token: string, expiresAt: Date): PasswordResetTokenEntity { + const entity = new PasswordResetTokenEntity(); + entity.userId = userId; + entity.token = token; + entity.expiresAt = expiresAt; + entity.used = false; + return entity; + } + + /** + * Check if token is expired or used + */ + isValid(): boolean { + return !this.used && new Date() <= this.expiresAt; + } + + /** + * Mark token as used + */ + markAsUsed(): void { + this.used = true; + } +} \ No newline at end of file diff --git a/packages/core/src/services/auth-service.ts b/packages/core/src/services/auth-service.ts new file mode 100644 index 00000000..ba167af8 --- /dev/null +++ b/packages/core/src/services/auth-service.ts @@ -0,0 +1,548 @@ +/** + * Authentication Service + * Manages user authentication, registration, and session handling + */ + +import 'reflect-metadata'; +import { DataSource, Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import * as jwt from 'jsonwebtoken'; +import * as crypto from 'crypto'; +import { + UserEntity, + UserProviderEntity, + EmailVerificationTokenEntity, + PasswordResetTokenEntity, +} from '../entities/user.entity.js'; +import type { + User, + UserRegistration, + UserLogin, + AuthResponse, + AuthToken, + SessionUser, + JWTPayload, + SSOUserInfo, + EmailVerificationToken, + PasswordResetToken, +} from '../types/index.js'; +import { createDataSource } from '../utils/typeorm-config.js'; + +interface AuthServiceInstance { + service: AuthService; + createdAt: number; +} + +export class AuthService { + private static instances: Map = new Map(); + private static readonly TTL_MS = 5 * 60 * 1000; // 5 minutes TTL + private database: DataSource; + private userRepository: Repository; + private providerRepository: Repository; + private emailTokenRepository: Repository; + private passwordResetRepository: Repository; + private initPromise: Promise | null = null; + + // Configuration + private readonly JWT_SECRET: string; + private readonly JWT_EXPIRES_IN = '15m'; // Access token expiry + private readonly JWT_REFRESH_EXPIRES_IN = '7d'; // Refresh token expiry + private readonly BCRYPT_ROUNDS = 12; + private readonly EMAIL_TOKEN_EXPIRES_HOURS = 24; + private readonly PASSWORD_RESET_EXPIRES_HOURS = 1; + + private constructor() { + this.database = createDataSource({ + entities: [ + UserEntity, + UserProviderEntity, + EmailVerificationTokenEntity, + PasswordResetTokenEntity, + ], + }); + this.userRepository = this.database.getRepository(UserEntity); + this.providerRepository = this.database.getRepository(UserProviderEntity); + this.emailTokenRepository = this.database.getRepository(EmailVerificationTokenEntity); + this.passwordResetRepository = this.database.getRepository(PasswordResetTokenEntity); + + // Get JWT secret from environment + this.JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key'; + if (this.JWT_SECRET === 'dev-secret-key' && process.env.NODE_ENV === 'production') { + throw new Error('JWT_SECRET must be set in production environment'); + } + } + + /** + * Get singleton instance with TTL + */ + static getInstance(): AuthService { + const instanceKey = 'default'; + const now = Date.now(); + const existingInstance = AuthService.instances.get(instanceKey); + + if (!existingInstance || now - existingInstance.createdAt > AuthService.TTL_MS) { + const newService = new AuthService(); + AuthService.instances.set(instanceKey, { + service: newService, + createdAt: now, + }); + return newService; + } + + return existingInstance.service; + } + + /** + * Initialize the database connection if not already initialized + */ + async ensureInitialized(): Promise { + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = this._initialize(); + return this.initPromise; + } + + /** + * Internal initialization method + */ + private async _initialize(): Promise { + if (!this.database.isInitialized) { + await this.database.initialize(); + } + } + + /** + * Dispose of the service and close database connection + */ + async dispose(): Promise { + if (this.database.isInitialized) { + await this.database.destroy(); + } + this.initPromise = null; + } + + /** + * Register a new user with email and password + */ + async register(registration: UserRegistration): Promise<{ user: User; emailToken?: string }> { + await this.ensureInitialized(); + + // Check if user already exists + const existingUser = await this.userRepository.findOne({ + where: { email: registration.email }, + }); + + if (existingUser) { + throw new Error('User with this email already exists'); + } + + // Hash password + const passwordHash = await bcrypt.hash(registration.password, this.BCRYPT_ROUNDS); + + // Create user entity + const userEntity = UserEntity.fromRegistration({ + email: registration.email, + name: registration.name, + passwordHash, + }); + + // Save user + const savedUser = await this.userRepository.save(userEntity); + + // Generate email verification token + const emailToken = await this.generateEmailVerificationToken(savedUser.id); + + return { + user: savedUser.toUser(), + emailToken: emailToken.token, + }; + } + + /** + * Login with email and password + */ + async login(login: UserLogin): Promise { + await this.ensureInitialized(); + + // Find user by email + const userEntity = await this.userRepository.findOne({ + where: { email: login.email }, + }); + + if (!userEntity) { + throw new Error('Invalid email or password'); + } + + // Verify password + const isPasswordValid = await bcrypt.compare(login.password, userEntity.passwordHash); + if (!isPasswordValid) { + throw new Error('Invalid email or password'); + } + + // Update last login + userEntity.updateLastLogin(); + await this.userRepository.save(userEntity); + + // Generate tokens + const tokens = await this.generateTokens(userEntity); + + return { + user: userEntity.toUser(), + tokens, + }; + } + + /** + * Verify email with token + */ + async verifyEmail(token: string): Promise { + await this.ensureInitialized(); + + const tokenEntity = await this.emailTokenRepository.findOne({ + where: { token }, + }); + + if (!tokenEntity || tokenEntity.isExpired()) { + throw new Error('Invalid or expired verification token'); + } + + // Find and update user + const userEntity = await this.userRepository.findOne({ + where: { id: tokenEntity.userId }, + }); + + if (!userEntity) { + throw new Error('User not found'); + } + + userEntity.isEmailVerified = true; + await this.userRepository.save(userEntity); + + // Delete used token + await this.emailTokenRepository.remove(tokenEntity); + + return userEntity.toUser(); + } + + /** + * Generate new access and refresh tokens + */ + async generateTokens(user: UserEntity): Promise { + const now = Math.floor(Date.now() / 1000); + + // Access token payload + const accessPayload: JWTPayload = { + userId: user.id, + email: user.email, + type: 'access', + iat: now, + exp: now + 15 * 60, // 15 minutes + }; + + // Refresh token payload + const refreshPayload: JWTPayload = { + userId: user.id, + email: user.email, + type: 'refresh', + iat: now, + exp: now + 7 * 24 * 60 * 60, // 7 days + }; + + const accessToken = jwt.sign(accessPayload, this.JWT_SECRET); + const refreshToken = jwt.sign(refreshPayload, this.JWT_SECRET); + + return { + accessToken, + refreshToken, + expiresAt: new Date(accessPayload.exp * 1000).toISOString(), + }; + } + + /** + * Verify and decode JWT token + */ + async verifyToken(token: string): Promise { + try { + const payload = jwt.verify(token, this.JWT_SECRET) as JWTPayload; + + if (payload.type !== 'access') { + throw new Error('Invalid token type'); + } + + // Get current user data + const user = await this.getUserById(payload.userId); + if (!user) { + throw new Error('User not found'); + } + + return { + id: user.id, + email: user.email, + name: user.name, + avatarUrl: user.avatarUrl, + isEmailVerified: user.isEmailVerified, + }; + } catch (error) { + throw new Error('Invalid or expired token'); + } + } + + /** + * Refresh access token using refresh token + */ + async refreshToken(refreshToken: string): Promise { + try { + const payload = jwt.verify(refreshToken, this.JWT_SECRET) as JWTPayload; + + if (payload.type !== 'refresh') { + throw new Error('Invalid token type'); + } + + // Get user and generate new tokens + const userEntity = await this.userRepository.findOne({ + where: { id: payload.userId }, + }); + + if (!userEntity) { + throw new Error('User not found'); + } + + return this.generateTokens(userEntity); + } catch (error) { + throw new Error('Invalid or expired refresh token'); + } + } + + /** + * Get user by ID + */ + async getUserById(id: number): Promise { + await this.ensureInitialized(); + + const userEntity = await this.userRepository.findOne({ + where: { id }, + }); + + return userEntity ? userEntity.toUser() : null; + } + + /** + * Get user by email + */ + async getUserByEmail(email: string): Promise { + await this.ensureInitialized(); + + const userEntity = await this.userRepository.findOne({ + where: { email }, + }); + + return userEntity ? userEntity.toUser() : null; + } + + /** + * Generate email verification token + */ + async generateEmailVerificationToken(userId: number): Promise { + await this.ensureInitialized(); + + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + this.EMAIL_TOKEN_EXPIRES_HOURS); + + const tokenEntity = EmailVerificationTokenEntity.createToken(userId, token, expiresAt); + const savedToken = await this.emailTokenRepository.save(tokenEntity); + + return savedToken.toEmailVerificationToken(); + } + + /** + * Generate password reset token + */ + async generatePasswordResetToken(email: string): Promise { + await this.ensureInitialized(); + + const user = await this.userRepository.findOne({ + where: { email }, + }); + + if (!user) { + // Don't reveal if email exists or not + return null; + } + + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + this.PASSWORD_RESET_EXPIRES_HOURS); + + const tokenEntity = PasswordResetTokenEntity.createToken(user.id, token, expiresAt); + const savedToken = await this.passwordResetRepository.save(tokenEntity); + + return savedToken.toPasswordResetToken(); + } + + /** + * Reset password using token + */ + async resetPassword(token: string, newPassword: string): Promise { + await this.ensureInitialized(); + + const tokenEntity = await this.passwordResetRepository.findOne({ + where: { token }, + }); + + if (!tokenEntity || !tokenEntity.isValid()) { + throw new Error('Invalid or expired reset token'); + } + + // Find user and update password + const userEntity = await this.userRepository.findOne({ + where: { id: tokenEntity.userId }, + }); + + if (!userEntity) { + throw new Error('User not found'); + } + + // Hash new password + const passwordHash = await bcrypt.hash(newPassword, this.BCRYPT_ROUNDS); + userEntity.passwordHash = passwordHash; + await this.userRepository.save(userEntity); + + // Mark token as used + tokenEntity.markAsUsed(); + await this.passwordResetRepository.save(tokenEntity); + + return userEntity.toUser(); + } + + /** + * Handle SSO login/registration + */ + async handleSSOLogin(ssoInfo: SSOUserInfo): Promise { + await this.ensureInitialized(); + + // Check if user already exists with this provider + let userEntity = await this.findUserByProvider(ssoInfo.provider, ssoInfo.providerId); + + if (!userEntity) { + // Check if user exists with this email + userEntity = await this.userRepository.findOne({ + where: { email: ssoInfo.email }, + }); + + if (userEntity) { + // Link SSO provider to existing user + await this.linkSSOProvider(userEntity.id, ssoInfo); + } else { + // Create new user + userEntity = await this.createUserFromSSO(ssoInfo); + } + } + + // Update last login + userEntity.updateLastLogin(); + await this.userRepository.save(userEntity); + + // Generate tokens + const tokens = await this.generateTokens(userEntity); + + return { + user: userEntity.toUser(), + tokens, + }; + } + + /** + * Find user by SSO provider + */ + private async findUserByProvider(provider: string, providerId: string): Promise { + const providerEntity = await this.providerRepository.findOne({ + where: { provider, providerId }, + relations: ['user'], + }); + + return providerEntity?.user || null; + } + + /** + * Link SSO provider to existing user + */ + private async linkSSOProvider(userId: number, ssoInfo: SSOUserInfo): Promise { + const providerEntity = UserProviderEntity.fromSSOInfo(userId, ssoInfo); + await this.providerRepository.save(providerEntity); + } + + /** + * Create new user from SSO information + */ + private async createUserFromSSO(ssoInfo: SSOUserInfo): Promise { + // Create user with random password (since they'll use SSO) + const randomPassword = crypto.randomBytes(32).toString('hex'); + const passwordHash = await bcrypt.hash(randomPassword, this.BCRYPT_ROUNDS); + + const userEntity = UserEntity.fromRegistration({ + email: ssoInfo.email, + name: ssoInfo.name, + passwordHash, + }); + + // SSO users are automatically email verified + userEntity.isEmailVerified = true; + userEntity.avatarUrl = ssoInfo.avatarUrl; + + const savedUser = await this.userRepository.save(userEntity); + + // Link SSO provider + await this.linkSSOProvider(savedUser.id, ssoInfo); + + return savedUser; + } + + /** + * Update user profile + */ + async updateUser(userId: number, updates: Partial): Promise { + await this.ensureInitialized(); + + const userEntity = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!userEntity) { + throw new Error('User not found'); + } + + userEntity.updateFromUserData(updates); + const savedUser = await this.userRepository.save(userEntity); + + return savedUser.toUser(); + } + + /** + * Change user password + */ + async changePassword(userId: number, currentPassword: string, newPassword: string): Promise { + await this.ensureInitialized(); + + const userEntity = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!userEntity) { + throw new Error('User not found'); + } + + // Verify current password + const isCurrentPasswordValid = await bcrypt.compare(currentPassword, userEntity.passwordHash); + if (!isCurrentPasswordValid) { + throw new Error('Current password is incorrect'); + } + + // Hash and save new password + const passwordHash = await bcrypt.hash(newPassword, this.BCRYPT_ROUNDS); + userEntity.passwordHash = passwordHash; + await this.userRepository.save(userEntity); + } +} \ No newline at end of file diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index bef1b075..5b881178 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -1,3 +1,4 @@ export { DevlogService } from './devlog-service.js'; export { ProjectService } from './project-service.js'; +// export { AuthService } from './auth-service.js'; // Moved to auth.ts export // export { IntegrationService } from './integration-service.js'; diff --git a/packages/core/src/types/auth.ts b/packages/core/src/types/auth.ts new file mode 100644 index 00000000..bdabf397 --- /dev/null +++ b/packages/core/src/types/auth.ts @@ -0,0 +1,138 @@ +/** + * Authentication and user management types + */ + +export interface User { + id: number; + email: string; + name?: string; + avatarUrl?: string; + isEmailVerified: boolean; + createdAt: string; + updatedAt: string; + lastLoginAt?: string; +} + +export interface UserRegistration { + email: string; + password: string; + name?: string; +} + +export interface UserLogin { + email: string; + password: string; +} + +export interface AuthToken { + accessToken: string; + refreshToken: string; + expiresAt: string; +} + +export interface AuthResponse { + user: User; + tokens: AuthToken; +} + +export interface PasswordReset { + email: string; +} + +export interface PasswordResetConfirm { + token: string; + newPassword: string; +} + +export interface EmailVerification { + token: string; +} + +// SSO Provider types +export type SSOProvider = 'github' | 'google' | 'wechat'; + +export interface SSOConfig { + github?: GitHubSSOConfig; + google?: GoogleSSOConfig; + wechat?: WeChatSSOConfig; +} + +export interface GitHubSSOConfig { + clientId: string; + clientSecret: string; + redirectUri: string; +} + +export interface GoogleSSOConfig { + clientId: string; + clientSecret: string; + redirectUri: string; +} + +export interface WeChatSSOConfig { + appId: string; + appSecret: string; + redirectUri: string; +} + +export interface SSOUserInfo { + provider: SSOProvider; + providerId: string; + email: string; + name?: string; + avatarUrl?: string; +} + +export interface UserProvider { + id: number; + userId: number; + provider: SSOProvider; + providerId: string; + createdAt: string; +} + +// Session and JWT types +export interface JWTPayload { + userId: number; + email: string; + type: 'access' | 'refresh'; + iat: number; + exp: number; +} + +export interface SessionUser { + id: number; + email: string; + name?: string; + avatarUrl?: string; + isEmailVerified: boolean; +} + +// Email verification types +export interface EmailVerificationToken { + id: number; + userId: number; + token: string; + expiresAt: string; + createdAt: string; +} + +export interface PasswordResetToken { + id: number; + userId: number; + token: string; + expiresAt: string; + createdAt: string; + used: boolean; +} + +// Auth error types +export interface AuthError { + code: string; + message: string; + details?: Record; +} + +export interface AuthValidationError extends AuthError { + field: string; +} \ No newline at end of file diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index a812abf0..8d062cdd 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -25,3 +25,6 @@ export * from './event.js'; // Change tracking and field history types export * from './change-tracking.js'; + +// Authentication and user management types +export * from './auth.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c174a1a..f2100525 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -330,6 +330,9 @@ importers: packages/core: dependencies: + bcrypt: + specifier: ^5.1.1 + version: 5.1.1 better-sqlite3: specifier: ^11.0.0 version: 11.10.0 @@ -339,6 +342,9 @@ importers: dotenv: specifier: 16.5.0 version: 16.5.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 mysql2: specifier: ^3.11.0 version: 3.14.1 @@ -355,9 +361,15 @@ importers: specifier: ^3.22.4 version: 3.25.67 devDependencies: + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 '@types/better-sqlite3': specifier: ^7.6.0 version: 7.6.13 + '@types/jsonwebtoken': + specifier: ^9.0.7 + version: 9.0.10 '@types/node': specifier: ^20.0.0 version: 20.19.1 @@ -796,6 +808,10 @@ packages: '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + '@modelcontextprotocol/sdk@1.13.0': resolution: {integrity: sha512-P5FZsXU0kY881F6Hbk9GhsYx02/KgWK1DYf7/tyE/1lcFKhDYPQR9iYjhQXJn+Sg6hQleMo3DB7h7+p4wgp2Lw==} engines: {node: '>=18'} @@ -1466,6 +1482,9 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@types/bcrypt@5.0.2': + resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} + '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -1511,6 +1530,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1614,6 +1636,9 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1627,6 +1652,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1665,6 +1694,14 @@ packages: resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} engines: {node: '>= 6.0.0'} + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -1706,6 +1743,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcrypt@5.1.1: + resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} + engines: {node: '>= 10.0.0'} + better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} @@ -1741,6 +1782,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1826,6 +1870,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1872,6 +1920,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -1905,6 +1957,9 @@ packages: engines: {node: '>=18'} hasBin: true + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -2037,6 +2092,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -2092,6 +2150,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2295,6 +2356,13 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2303,6 +2371,11 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} @@ -2344,6 +2417,10 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2370,6 +2447,9 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2433,6 +2513,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -2448,6 +2532,10 @@ packages: ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2578,6 +2666,16 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -2661,12 +2759,30 @@ packages: lodash.castarray@4.4.0: resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -2716,6 +2832,10 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -2906,13 +3026,30 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2975,6 +3112,9 @@ packages: resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==} engines: {node: '>=10'} + node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2992,6 +3132,11 @@ packages: engines: {node: '>=10'} hasBin: true + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3000,6 +3145,10 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -3052,6 +3201,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3420,6 +3573,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rimraf@5.0.10: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true @@ -3448,6 +3606,10 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -3464,6 +3626,9 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3507,6 +3672,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3681,6 +3849,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + terser@5.43.1: resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==} engines: {node: '>=10'} @@ -4071,6 +4243,9 @@ packages: engines: {node: '>=8'} hasBin: true + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4106,6 +4281,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.8.0: resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} @@ -4374,6 +4552,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.0.4 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.7.2 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + '@modelcontextprotocol/sdk@1.13.0': dependencies: ajv: 6.12.6 @@ -5010,6 +5203,10 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.17 + '@types/bcrypt@5.0.2': + dependencies: + '@types/node': 20.19.1 + '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 20.19.1 @@ -5056,6 +5253,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.1 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -5189,6 +5391,8 @@ snapshots: loupe: 3.1.4 tinyrainbow: 1.2.0 + abbrev@1.1.1: {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -5201,6 +5405,12 @@ snapshots: acorn@8.15.0: optional: true + agent-base@6.0.2: + dependencies: + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -5233,6 +5443,13 @@ snapshots: app-root-path@3.1.0: {} + aproba@2.1.0: {} + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + arg@5.0.2: {} aria-hidden@1.2.6: @@ -5273,6 +5490,14 @@ snapshots: base64-js@1.5.1: {} + bcrypt@5.1.1: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + node-addon-api: 5.1.0 + transitivePeerDependencies: + - encoding + - supports-color + better-sqlite3@11.10.0: dependencies: bindings: 1.5.0 @@ -5326,6 +5551,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.3) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: optional: true @@ -5432,6 +5659,8 @@ snapshots: chownr@1.1.4: {} + chownr@2.0.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -5483,6 +5712,8 @@ snapshots: color-name@1.1.4: {} + color-support@1.1.3: {} + colorette@2.0.20: {} combined-stream@1.0.8: @@ -5512,6 +5743,8 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 + console-control-strings@1.1.0: {} + content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -5623,6 +5856,8 @@ snapshots: delayed-stream@1.0.0: {} + delegates@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} @@ -5674,6 +5909,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.208: {} @@ -5910,11 +6149,29 @@ snapshots: fs-constants@1.0.0: {} + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true function-bind@1.1.2: {} + gauge@3.0.2: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + generate-function@2.3.1: dependencies: is-property: 1.0.2 @@ -5966,6 +6223,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -5984,6 +6250,8 @@ snapshots: dependencies: has-symbols: 1.1.0 + has-unicode@2.0.1: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -6114,6 +6382,13 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + husky@9.1.7: {} iconv-lite@0.6.3: @@ -6124,6 +6399,11 @@ snapshots: ignore-by-default@1.0.1: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@1.3.8: {} @@ -6226,6 +6506,30 @@ snapshots: json-schema-traverse@0.4.1: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.2 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + lightningcss-darwin-arm64@1.30.1: optional: true @@ -6302,10 +6606,22 @@ snapshots: lodash.castarray@4.4.0: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + lodash.isplainobject@4.0.6: {} + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} log-symbols@6.0.0: @@ -6357,6 +6673,10 @@ snapshots: '@babel/types': 7.28.1 source-map-js: 1.2.1 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + make-dir@4.0.0: dependencies: semver: 7.7.2 @@ -6746,10 +7066,23 @@ snapshots: minimist@1.2.8: {} + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + minipass@7.1.2: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -6818,6 +7151,8 @@ snapshots: dependencies: semver: 7.7.2 + node-addon-api@5.1.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -6837,10 +7172,21 @@ snapshots: touch: 3.1.1 undefsafe: 2.0.5 + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + normalize-path@3.0.0: {} normalize-range@0.1.2: {} + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -6904,6 +7250,8 @@ snapshots: parseurl@1.3.3: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -7324,6 +7672,10 @@ snapshots: rfdc@1.4.1: {} + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + rimraf@5.0.10: dependencies: glob: 10.4.5 @@ -7380,6 +7732,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + semver@6.3.1: {} + semver@7.7.2: {} send@1.2.0: @@ -7409,6 +7763,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -7464,6 +7820,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: {} @@ -7654,6 +8012,15 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + terser@5.43.1: dependencies: '@jridgewell/source-map': 0.3.10 @@ -8020,6 +8387,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -8046,6 +8417,8 @@ snapshots: y18n@5.0.8: {} + yallist@4.0.0: {} + yaml@2.8.0: {} yargs-parser@21.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index efb95e02..7bbf236b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ packages: - apps/* onlyBuiltDependencies: + - bcrypt - better-sqlite3 - esbuild From f3e962e9c6ce7315620b894b25a8ee5fd773fc84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 Aug 2025 08:58:34 +0000 Subject: [PATCH 3/3] Add authentication UI components and pages - functional login/register forms Co-authored-by: tikazyq <3393101+tikazyq@users.noreply.github.com> --- apps/web/.devlog/devlog.sqlite | 0 apps/web/app/api/auth/me/route.ts | 37 +++- apps/web/app/login/page.tsx | 37 ++++ apps/web/app/register/page.tsx | 37 ++++ apps/web/components/auth/auth-context.tsx | 157 ++++++++++++++++ apps/web/components/auth/auth-guard.tsx | 74 ++++++++ apps/web/components/auth/index.ts | 4 + apps/web/components/auth/login-form.tsx | 114 ++++++++++++ apps/web/components/auth/register-form.tsx | 175 ++++++++++++++++++ .../web/components/provider/app-providers.tsx | 9 +- apps/web/next.config.js | 14 ++ 11 files changed, 647 insertions(+), 11 deletions(-) create mode 100644 apps/web/.devlog/devlog.sqlite create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/app/register/page.tsx create mode 100644 apps/web/components/auth/auth-context.tsx create mode 100644 apps/web/components/auth/auth-guard.tsx create mode 100644 apps/web/components/auth/index.ts create mode 100644 apps/web/components/auth/login-form.tsx create mode 100644 apps/web/components/auth/register-form.tsx diff --git a/apps/web/.devlog/devlog.sqlite b/apps/web/.devlog/devlog.sqlite new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/app/api/auth/me/route.ts b/apps/web/app/api/auth/me/route.ts index 2b1830a4..4fab942d 100644 --- a/apps/web/app/api/auth/me/route.ts +++ b/apps/web/app/api/auth/me/route.ts @@ -3,11 +3,32 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/auth-middleware'; - -export const GET = withAuth(async (req) => { - return NextResponse.json({ - success: true, - user: req.user, - }, { status: 200 }); -}); \ No newline at end of file + +export async function GET(req: NextRequest) { + try { + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json({ error: 'Missing or invalid authorization header' }, { + status: 401, + }); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + // Dynamic import to keep server-only + const { AuthService } = await import('@codervisor/devlog-core/auth'); + const authService = AuthService.getInstance(); + + const user = await authService.verifyToken(token); + + return NextResponse.json({ + success: true, + user, + }, { status: 200 }); + + } catch (error) { + return NextResponse.json({ error: 'Invalid or expired token' }, { + status: 401, + }); + } +} \ No newline at end of file diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 00000000..d8d94ea7 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,37 @@ +/** + * Login page + */ + +import Link from 'next/link'; +import { LoginForm } from '@/components/auth'; + +export default function LoginPage() { + return ( +
+
+
+

+ Welcome Back +

+

+ Sign in to your devlog account +

+
+ + + +
+

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/app/register/page.tsx b/apps/web/app/register/page.tsx new file mode 100644 index 00000000..1a6c9f80 --- /dev/null +++ b/apps/web/app/register/page.tsx @@ -0,0 +1,37 @@ +/** + * Register page + */ + +import Link from 'next/link'; +import { RegisterForm } from '@/components/auth'; + +export default function RegisterPage() { + return ( +
+
+
+

+ Create Account +

+

+ Join devlog to start tracking your development progress +

+
+ + + +
+

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/components/auth/auth-context.tsx b/apps/web/components/auth/auth-context.tsx new file mode 100644 index 00000000..07690a07 --- /dev/null +++ b/apps/web/components/auth/auth-context.tsx @@ -0,0 +1,157 @@ +/** + * Authentication context and hook + */ + +'use client'; + +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; + +interface User { + id: number; + email: string; + name?: string; + avatarUrl?: string; + isEmailVerified: boolean; +} + +interface AuthContextType { + user: User | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => void; + refreshToken: () => Promise; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // Check for existing auth on mount + useEffect(() => { + checkAuth(); + }, []); + + const checkAuth = async () => { + try { + const token = localStorage.getItem('accessToken'); + if (!token) { + setLoading(false); + return; + } + + const response = await fetch('/api/auth/me', { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setUser(data.user); + } else { + // Token might be expired, try to refresh + await refreshToken(); + } + } catch (error) { + console.error('Auth check failed:', error); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } finally { + setLoading(false); + } + }; + + const login = async (email: string, password: string) => { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Login failed'); + } + + const data = await response.json(); + localStorage.setItem('accessToken', data.tokens.accessToken); + localStorage.setItem('refreshToken', data.tokens.refreshToken); + setUser(data.user); + }; + + const logout = () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + setUser(null); + }; + + const refreshToken = async () => { + try { + const refreshTokenValue = localStorage.getItem('refreshToken'); + if (!refreshTokenValue) { + throw new Error('No refresh token available'); + } + + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken: refreshTokenValue }), + }); + + if (!response.ok) { + throw new Error('Token refresh failed'); + } + + const data = await response.json(); + localStorage.setItem('accessToken', data.tokens.accessToken); + localStorage.setItem('refreshToken', data.tokens.refreshToken); + + // Re-check auth to get updated user info + await checkAuth(); + } catch (error) { + console.error('Token refresh failed:', error); + logout(); + throw error; + } + }; + + const value = { + user, + loading, + login, + logout, + refreshToken, + }; + + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +// Helper hook for auth state +export function useUser() { + const { user } = useAuth(); + return user; +} + +// Helper hook to check if user is authenticated +export function useIsAuthenticated() { + const { user, loading } = useAuth(); + return { isAuthenticated: !!user, loading }; +} \ No newline at end of file diff --git a/apps/web/components/auth/auth-guard.tsx b/apps/web/components/auth/auth-guard.tsx new file mode 100644 index 00000000..313b3f16 --- /dev/null +++ b/apps/web/components/auth/auth-guard.tsx @@ -0,0 +1,74 @@ +/** + * Auth guard component to protect routes + */ + +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from './auth-context'; +import { Loader2 } from 'lucide-react'; + +interface AuthGuardProps { + children: React.ReactNode; + redirectTo?: string; + requireEmailVerification?: boolean; +} + +export function AuthGuard({ + children, + redirectTo = '/login', + requireEmailVerification = false +}: AuthGuardProps) { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading) { + if (!user) { + router.push(redirectTo); + return; + } + + if (requireEmailVerification && !user.isEmailVerified) { + router.push('/verify-email'); + return; + } + } + }, [user, loading, router, redirectTo, requireEmailVerification]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!user) { + return null; // Will redirect + } + + if (requireEmailVerification && !user.isEmailVerified) { + return null; // Will redirect to email verification + } + + return <>{children}; +} + +// Wrapper for pages that require authentication +export function withAuth

( + Component: React.ComponentType

, + options?: { + redirectTo?: string; + requireEmailVerification?: boolean; + } +) { + return function AuthenticatedComponent(props: P) { + return ( + + + + ); + }; +} \ No newline at end of file diff --git a/apps/web/components/auth/index.ts b/apps/web/components/auth/index.ts new file mode 100644 index 00000000..a5a9d783 --- /dev/null +++ b/apps/web/components/auth/index.ts @@ -0,0 +1,4 @@ +export { LoginForm } from './login-form'; +export { RegisterForm } from './register-form'; +export { AuthProvider, useAuth, useUser, useIsAuthenticated } from './auth-context'; +export { AuthGuard, withAuth } from './auth-guard'; \ No newline at end of file diff --git a/apps/web/components/auth/login-form.tsx b/apps/web/components/auth/login-form.tsx new file mode 100644 index 00000000..bb37cd52 --- /dev/null +++ b/apps/web/components/auth/login-form.tsx @@ -0,0 +1,114 @@ +/** + * Login form component + */ + +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2 } from 'lucide-react'; + +interface LoginFormProps { + onSuccess?: (user: any, tokens: any) => void; + redirectTo?: string; +} + +export function LoginForm({ onSuccess, redirectTo = '/projects' }: LoginFormProps) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Login failed'); + } + + // Store tokens in localStorage (in a real app, consider httpOnly cookies) + localStorage.setItem('accessToken', data.tokens.accessToken); + localStorage.setItem('refreshToken', data.tokens.refreshToken); + + if (onSuccess) { + onSuccess(data.user, data.tokens); + } else { + router.push(redirectTo); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( + + + Sign In + + Enter your email and password to access your account + + + +

+ {error && ( + + {error} + + )} + +
+ + setEmail(e.target.value)} + placeholder="Enter your email" + required + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + disabled={loading} + /> +
+ + +
+ + + ); +} \ No newline at end of file diff --git a/apps/web/components/auth/register-form.tsx b/apps/web/components/auth/register-form.tsx new file mode 100644 index 00000000..11ba4174 --- /dev/null +++ b/apps/web/components/auth/register-form.tsx @@ -0,0 +1,175 @@ +/** + * Registration form component + */ + +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2 } from 'lucide-react'; + +interface RegisterFormProps { + onSuccess?: (user: any) => void; + redirectTo?: string; +} + +export function RegisterForm({ onSuccess, redirectTo = '/login' }: RegisterFormProps) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + setLoading(false); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters long'); + setLoading(false); + return; + } + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password, name: name || undefined }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Registration failed'); + } + + setSuccess(true); + + if (onSuccess) { + onSuccess(data.user); + } else { + // Redirect to login page after successful registration + setTimeout(() => { + router.push(redirectTo); + }, 2000); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed'); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( + + + Registration Successful! + + Please check your email for a verification link. + + + + + + We've sent a verification email to {email}. Please click the link in the email to verify your account. + + + + + ); + } + + return ( + + + Create Account + + Enter your information to create a new account + + + +
+ {error && ( + + {error} + + )} + +
+ + setName(e.target.value)} + placeholder="Enter your full name" + disabled={loading} + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="Enter your email" + required + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password (min 8 characters)" + required + disabled={loading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + required + disabled={loading} + /> +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/components/provider/app-providers.tsx b/apps/web/components/provider/app-providers.tsx index d1448297..d0f1993f 100644 --- a/apps/web/components/provider/app-providers.tsx +++ b/apps/web/components/provider/app-providers.tsx @@ -3,13 +3,16 @@ import { ThemeProvider } from '@/components/provider/theme-provider'; import { AppLayout } from '@/components/layout/app-layout'; import { StoreProvider } from '@/components/provider/store-provider'; +import { AuthProvider } from '@/components/auth'; export function AppProviders({ children }: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); } diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 643e2354..0479d971 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -39,6 +39,15 @@ const nextConfig = { if (isServer) { // Ensure these packages are treated as externals for server-side config.externals = config.externals || []; + config.externals.push( + 'bcrypt', + 'jsonwebtoken', + '@mapbox/node-pre-gyp', + 'node-pre-gyp', + 'mock-aws-s3', + 'aws-sdk', + 'nock' + ); } // Fix Monaco Editor issues for client-side @@ -66,6 +75,11 @@ const nextConfig = { // Exclude authentication modules from client bundle 'bcrypt': false, 'jsonwebtoken': false, + '@mapbox/node-pre-gyp': false, + 'node-pre-gyp': false, + 'mock-aws-s3': false, + 'aws-sdk': false, + 'nock': false, // Exclude problematic TypeORM drivers 'react-native-sqlite-storage': false, '@sap/hana-client': false,