From e28d8b28a770fafddef026476dd4a087417dbb32 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Dec 2025 05:07:47 +0000 Subject: [PATCH 01/10] refactor: isolate auth module using inversion of control Create a standalone auth module at ~/auth that encapsulates all authentication logic, making it independent of the rest of the application through IoC interfaces. New auth module structure: - types.ts: Core types and IoC interfaces (IUserRepository, etc.) - session.server.ts: Cookie-based session management with HMAC-SHA256 - auth.server.ts: Main AuthService for user authentication - capabilities.server.ts: CapabilitiesService with helper utilities - oauth.server.ts: OAuthService and OAuth provider utilities - guards.server.ts: Auth guards factory and decorators - repositories.server.ts: Drizzle-based repository implementations - context.server.ts: Dependency injection/service composition root - index.server.ts: Server-side public API exports - index.ts: Client-side public API exports - client.ts: Client-side auth utilities The existing utils files now delegate to the new auth module for backward compatibility. Auth routes updated to use the new module directly. --- src/auth/auth.server.ts | 163 +++++++++ src/auth/capabilities.server.ts | 101 ++++++ src/auth/client.ts | 71 ++++ src/auth/context.server.ts | 146 ++++++++ src/auth/guards.server.ts | 146 ++++++++ src/auth/index.server.ts | 129 +++++++ src/auth/index.ts | 57 ++++ src/auth/oauth.server.ts | 379 +++++++++++++++++++++ src/auth/repositories.server.ts | 275 +++++++++++++++ src/auth/session.server.ts | 272 +++++++++++++++ src/auth/types.ts | 224 ++++++++++++ src/routes/api/auth/callback/$provider.tsx | 318 +++++++---------- src/routes/auth/$provider/start.tsx | 42 +-- src/routes/auth/signout.tsx | 15 +- src/utils/auth.client.ts | 21 +- src/utils/auth.server-helpers.ts | 96 ++---- src/utils/auth.server.ts | 56 +-- src/utils/capabilities.server.ts | 119 ++----- src/utils/cookies.server.ts | 240 ++----------- src/utils/oauth.server.ts | 180 ++-------- 20 files changed, 2249 insertions(+), 801 deletions(-) create mode 100644 src/auth/auth.server.ts create mode 100644 src/auth/capabilities.server.ts create mode 100644 src/auth/client.ts create mode 100644 src/auth/context.server.ts create mode 100644 src/auth/guards.server.ts create mode 100644 src/auth/index.server.ts create mode 100644 src/auth/index.ts create mode 100644 src/auth/oauth.server.ts create mode 100644 src/auth/repositories.server.ts create mode 100644 src/auth/session.server.ts create mode 100644 src/auth/types.ts diff --git a/src/auth/auth.server.ts b/src/auth/auth.server.ts new file mode 100644 index 00000000..ca66f728 --- /dev/null +++ b/src/auth/auth.server.ts @@ -0,0 +1,163 @@ +/** + * Auth Service + * + * Main authentication service that coordinates session validation + * and user retrieval. Uses inversion of control for all dependencies. + */ + +import type { + AuthUser, + Capability, + DbUser, + IAuthService, + ICapabilitiesRepository, + ISessionService, + IUserRepository, + SessionCookieData, +} from './types' +import { AuthError } from './types' + +// ============================================================================ +// Auth Service Implementation +// ============================================================================ + +export class AuthService implements IAuthService { + constructor( + private sessionService: ISessionService, + private userRepository: IUserRepository, + private capabilitiesRepository: ICapabilitiesRepository, + ) {} + + /** + * Get current user from request + * Returns null if not authenticated + */ + async getCurrentUser(request: Request): Promise { + const signedCookie = this.sessionService.getSessionCookie(request) + + if (!signedCookie) { + return null + } + + try { + const cookieData = await this.sessionService.verifyCookie(signedCookie) + + if (!cookieData) { + console.error( + '[AuthService] Session cookie verification failed - invalid signature or expired', + ) + return null + } + + const result = await this.validateSession(cookieData) + if (!result) { + return null + } + + return this.mapDbUserToAuthUser(result.user, result.capabilities) + } catch (error) { + console.error('[AuthService] Failed to get user from session:', { + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + }) + return null + } + } + + /** + * Validate session data against the database + */ + async validateSession( + sessionData: SessionCookieData, + ): Promise<{ user: DbUser; capabilities: Capability[] } | null> { + const user = await this.userRepository.findById(sessionData.userId) + + if (!user) { + console.error( + `[AuthService] Session cookie references non-existent user ${sessionData.userId}`, + ) + return null + } + + // Verify session version matches (for session revocation) + if (user.sessionVersion !== sessionData.version) { + console.error( + `[AuthService] Session version mismatch for user ${user.id} - expected ${user.sessionVersion}, got ${sessionData.version}`, + ) + return null + } + + // Get effective capabilities + const capabilities = + await this.capabilitiesRepository.getEffectiveCapabilities(user.id) + + return { user, capabilities } + } + + /** + * Map database user to AuthUser type + */ + private mapDbUserToAuthUser(user: DbUser, capabilities: Capability[]): AuthUser { + return { + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + displayUsername: user.displayUsername, + capabilities, + adsDisabled: user.adsDisabled, + interestedInHidingAds: user.interestedInHidingAds, + } + } +} + +// ============================================================================ +// Auth Guard Functions +// ============================================================================ + +/** + * Require authentication - throws if not authenticated + */ +export async function requireAuthentication( + authService: IAuthService, + request: Request, +): Promise { + const user = await authService.getCurrentUser(request) + if (!user) { + throw new AuthError('Not authenticated', 'NOT_AUTHENTICATED') + } + return user +} + +/** + * Require specific capability - throws if not authorized + */ +export async function requireCapability( + authService: IAuthService, + request: Request, + capability: Capability, +): Promise { + const user = await requireAuthentication(authService, request) + + const hasAccess = + user.capabilities.includes('admin') || user.capabilities.includes(capability) + + if (!hasAccess) { + throw new AuthError( + `Missing required capability: ${capability}`, + 'MISSING_CAPABILITY', + ) + } + + return user +} + +/** + * Require admin capability - throws if not admin + */ +export async function requireAdmin( + authService: IAuthService, + request: Request, +): Promise { + return requireCapability(authService, request, 'admin') +} diff --git a/src/auth/capabilities.server.ts b/src/auth/capabilities.server.ts new file mode 100644 index 00000000..25106877 --- /dev/null +++ b/src/auth/capabilities.server.ts @@ -0,0 +1,101 @@ +/** + * Capabilities Service + * + * Handles authorization via capability-based access control. + * Uses inversion of control for data access. + */ + +import type { Capability, ICapabilitiesRepository, AuthUser } from './types' + +// ============================================================================ +// Capabilities Service +// ============================================================================ + +export class CapabilitiesService { + constructor(private repository: ICapabilitiesRepository) {} + + /** + * Get effective capabilities for a user (direct + role-based) + */ + async getEffectiveCapabilities(userId: string): Promise { + return this.repository.getEffectiveCapabilities(userId) + } + + /** + * Get effective capabilities for multiple users efficiently + */ + async getBulkEffectiveCapabilities( + userIds: string[], + ): Promise> { + return this.repository.getBulkEffectiveCapabilities(userIds) + } +} + +// ============================================================================ +// Capability Checking Utilities +// ============================================================================ + +/** + * Check if user has a specific capability + * Admin users have access to all capabilities + */ +export function hasCapability( + capabilities: Capability[], + requiredCapability: Capability, +): boolean { + return ( + capabilities.includes('admin') || capabilities.includes(requiredCapability) + ) +} + +/** + * Check if user has all specified capabilities + */ +export function hasAllCapabilities( + capabilities: Capability[], + requiredCapabilities: Capability[], +): boolean { + if (capabilities.includes('admin')) { + return true + } + return requiredCapabilities.every((cap) => capabilities.includes(cap)) +} + +/** + * Check if user has any of the specified capabilities + */ +export function hasAnyCapability( + capabilities: Capability[], + requiredCapabilities: Capability[], +): boolean { + if (capabilities.includes('admin')) { + return true + } + return requiredCapabilities.some((cap) => capabilities.includes(cap)) +} + +/** + * Check if user is admin + */ +export function isAdmin(capabilities: Capability[]): boolean { + return capabilities.includes('admin') +} + +/** + * Check if AuthUser has a specific capability + */ +export function userHasCapability( + user: AuthUser | null | undefined, + capability: Capability, +): boolean { + if (!user) return false + return hasCapability(user.capabilities, capability) +} + +/** + * Check if AuthUser is admin + */ +export function userIsAdmin(user: AuthUser | null | undefined): boolean { + if (!user) return false + return isAdmin(user.capabilities) +} diff --git a/src/auth/client.ts b/src/auth/client.ts new file mode 100644 index 00000000..9e509769 --- /dev/null +++ b/src/auth/client.ts @@ -0,0 +1,71 @@ +/** + * Auth Client Module + * + * Client-side authentication utilities and navigation helpers. + * This module is safe to import in browser code. + */ + +import type { OAuthProvider } from './types' + +// ============================================================================ +// Auth Client +// ============================================================================ + +/** + * Client-side auth utilities for OAuth flows + */ +export const authClient = { + signIn: { + /** + * Initiate OAuth sign-in with a social provider + */ + social: ({ provider }: { provider: OAuthProvider }) => { + window.location.href = `/auth/${provider}/start` + }, + }, + + /** + * Sign out the current user + */ + signOut: async () => { + window.location.href = '/auth/signout' + }, +} + +// ============================================================================ +// Navigation Helpers +// ============================================================================ + +/** + * Navigate to sign-in page + */ +export function navigateToSignIn( + provider?: OAuthProvider, + returnTo?: string, +): void { + if (provider) { + const url = returnTo + ? `/auth/${provider}/start?returnTo=${encodeURIComponent(returnTo)}` + : `/auth/${provider}/start` + window.location.href = url + } else { + const url = returnTo + ? `/login?returnTo=${encodeURIComponent(returnTo)}` + : '/login' + window.location.href = url + } +} + +/** + * Navigate to sign-out + */ +export function navigateToSignOut(): void { + window.location.href = '/auth/signout' +} + +/** + * Get current URL path for return-to parameter + */ +export function getCurrentPath(): string { + return window.location.pathname + window.location.search +} diff --git a/src/auth/context.server.ts b/src/auth/context.server.ts new file mode 100644 index 00000000..80b6979a --- /dev/null +++ b/src/auth/context.server.ts @@ -0,0 +1,146 @@ +/** + * Auth Context Setup + * + * Creates and configures the auth services with their dependencies. + * This is the composition root for the auth module in this application. + */ + +import { AuthService } from './auth.server' +import { CapabilitiesService } from './capabilities.server' +import { OAuthService } from './oauth.server' +import { SessionService } from './session.server' +import { createAuthGuards } from './guards.server' +import { + createRepositories, + DrizzleUserRepository, + DrizzleOAuthAccountRepository, + DrizzleCapabilitiesRepository, +} from './repositories.server' + +// ============================================================================ +// Environment Configuration +// ============================================================================ + +function getSessionSecret(): string { + return ( + process.env.SESSION_SECRET || 'dev-secret-key-change-in-production' + ) +} + +function isProduction(): boolean { + return process.env.NODE_ENV === 'production' +} + +// ============================================================================ +// Service Instances (Singleton pattern) +// ============================================================================ + +// Repositories +let _userRepository: DrizzleUserRepository | null = null +let _oauthAccountRepository: DrizzleOAuthAccountRepository | null = null +let _capabilitiesRepository: DrizzleCapabilitiesRepository | null = null + +// Services +let _sessionService: SessionService | null = null +let _authService: AuthService | null = null +let _capabilitiesService: CapabilitiesService | null = null +let _oauthService: OAuthService | null = null + +// ============================================================================ +// Repository Getters +// ============================================================================ + +export function getUserRepository(): DrizzleUserRepository { + if (!_userRepository) { + _userRepository = new DrizzleUserRepository() + } + return _userRepository +} + +export function getOAuthAccountRepository(): DrizzleOAuthAccountRepository { + if (!_oauthAccountRepository) { + _oauthAccountRepository = new DrizzleOAuthAccountRepository() + } + return _oauthAccountRepository +} + +export function getCapabilitiesRepository(): DrizzleCapabilitiesRepository { + if (!_capabilitiesRepository) { + _capabilitiesRepository = new DrizzleCapabilitiesRepository() + } + return _capabilitiesRepository +} + +// ============================================================================ +// Service Getters +// ============================================================================ + +export function getSessionService(): SessionService { + if (!_sessionService) { + _sessionService = new SessionService(getSessionSecret(), isProduction()) + } + return _sessionService +} + +export function getAuthService(): AuthService { + if (!_authService) { + _authService = new AuthService( + getSessionService(), + getUserRepository(), + getCapabilitiesRepository(), + ) + } + return _authService +} + +export function getCapabilitiesService(): CapabilitiesService { + if (!_capabilitiesService) { + _capabilitiesService = new CapabilitiesService(getCapabilitiesRepository()) + } + return _capabilitiesService +} + +export function getOAuthService(): OAuthService { + if (!_oauthService) { + _oauthService = new OAuthService( + getOAuthAccountRepository(), + getUserRepository(), + ) + } + return _oauthService +} + +// ============================================================================ +// Auth Guards (bound to auth service) +// ============================================================================ + +let _authGuards: ReturnType | null = null + +export function getAuthGuards() { + if (!_authGuards) { + _authGuards = createAuthGuards(getAuthService()) + } + return _authGuards +} + +// ============================================================================ +// Convenience Exports +// ============================================================================ + +/** + * Get all auth services configured for this application + */ +export function getAuthContext() { + return { + sessionService: getSessionService(), + authService: getAuthService(), + capabilitiesService: getCapabilitiesService(), + oauthService: getOAuthService(), + guards: getAuthGuards(), + repositories: { + user: getUserRepository(), + oauthAccount: getOAuthAccountRepository(), + capabilities: getCapabilitiesRepository(), + }, + } +} diff --git a/src/auth/guards.server.ts b/src/auth/guards.server.ts new file mode 100644 index 00000000..81f5061b --- /dev/null +++ b/src/auth/guards.server.ts @@ -0,0 +1,146 @@ +/** + * Auth Guards Module + * + * Provides guard functions for protecting routes and server functions. + * These are convenience wrappers around the auth service. + */ + +import type { AuthUser, Capability, IAuthService } from './types' +import { AuthError } from './types' + +// ============================================================================ +// Guard Factory +// ============================================================================ + +/** + * Create guard functions bound to an auth service instance + */ +export function createAuthGuards(authService: IAuthService) { + return { + /** + * Get current user (non-blocking, returns null if not authenticated) + */ + async getCurrentUser(request: Request): Promise { + try { + return await authService.getCurrentUser(request) + } catch { + return null + } + }, + + /** + * Require authentication (throws if not authenticated) + */ + async requireAuth(request: Request): Promise { + const user = await authService.getCurrentUser(request) + if (!user) { + throw new AuthError('Not authenticated', 'NOT_AUTHENTICATED') + } + return user + }, + + /** + * Require specific capability (throws if not authorized) + */ + async requireCapability( + request: Request, + capability: Capability, + ): Promise { + const user = await authService.getCurrentUser(request) + if (!user) { + throw new AuthError('Not authenticated', 'NOT_AUTHENTICATED') + } + + const hasAccess = + user.capabilities.includes('admin') || + user.capabilities.includes(capability) + + if (!hasAccess) { + throw new AuthError( + `Missing required capability: ${capability}`, + 'MISSING_CAPABILITY', + ) + } + + return user + }, + + /** + * Require admin access + */ + async requireAdmin(request: Request): Promise { + return this.requireCapability(request, 'admin') + }, + + /** + * Check if user has capability (non-throwing) + */ + async hasCapability( + request: Request, + capability: Capability, + ): Promise { + try { + await this.requireCapability(request, capability) + return true + } catch { + return false + } + }, + + /** + * Check if user is authenticated (non-throwing) + */ + async isAuthenticated(request: Request): Promise { + const user = await this.getCurrentUser(request) + return user !== null + }, + + /** + * Check if user is admin (non-throwing) + */ + async isAdmin(request: Request): Promise { + return this.hasCapability(request, 'admin') + }, + } +} + +// ============================================================================ +// Guard Types +// ============================================================================ + +export type AuthGuards = ReturnType + +// ============================================================================ +// Capability Guard Decorator Pattern (for server functions) +// ============================================================================ + +/** + * Create a guard that wraps a handler with capability check + */ +export function withCapability unknown>( + guards: AuthGuards, + capability: Capability, + getRequest: () => Request, + handler: (user: AuthUser, ...args: Parameters) => ReturnType, +): (...args: Parameters) => Promise>> { + return async (...args: Parameters) => { + const request = getRequest() + const user = await guards.requireCapability(request, capability) + return handler(user, ...args) as Awaited> + } +} + +/** + * Create a guard that wraps a handler with auth check + */ +export function withAuth unknown>( + guards: AuthGuards, + getRequest: () => Request, + handler: (user: AuthUser, ...args: Parameters) => ReturnType, +): (...args: Parameters) => Promise>> { + return async (...args: Parameters) => { + const request = getRequest() + const user = await guards.requireAuth(request) + return handler(user, ...args) as Awaited> + } +} diff --git a/src/auth/index.server.ts b/src/auth/index.server.ts new file mode 100644 index 00000000..e0ca0da7 --- /dev/null +++ b/src/auth/index.server.ts @@ -0,0 +1,129 @@ +/** + * Auth Module - Server Entry Point + * + * This is the main entry point for server-side auth functionality. + * Import from '~/auth/index.server' for server-side code. + * + * Example usage: + * + * ```ts + * import { getAuthService, getAuthGuards } from '~/auth/index.server' + * + * // In a server function or loader + * const authService = getAuthService() + * const user = await authService.getCurrentUser(request) + * + * // Or use guards + * const guards = getAuthGuards() + * const user = await guards.requireAuth(request) + * ``` + */ + +// ============================================================================ +// Types (re-exported from types module) +// ============================================================================ + +export type { + // Core types + Capability, + OAuthProvider, + OAuthProfile, + OAuthResult, + SessionCookieData, + AuthUser, + DbUser, + // Interfaces for IoC + IUserRepository, + IOAuthAccountRepository, + ICapabilitiesRepository, + ISessionService, + IAuthService, + IOAuthService, + AuthContext, + // Error types + AuthErrorCode, +} from './types' + +export { VALID_CAPABILITIES, AuthError } from './types' + +// ============================================================================ +// Services +// ============================================================================ + +export { AuthService } from './auth.server' +export { SessionService } from './session.server' +export { CapabilitiesService } from './capabilities.server' +export { OAuthService } from './oauth.server' + +// ============================================================================ +// Session Utilities +// ============================================================================ + +export { + generateOAuthState, + createOAuthStateCookie, + clearOAuthStateCookie, + getOAuthStateCookie, + SESSION_DURATION_MS, + SESSION_MAX_AGE_SECONDS, +} from './session.server' + +// ============================================================================ +// Capability Utilities +// ============================================================================ + +export { + hasCapability, + hasAllCapabilities, + hasAnyCapability, + isAdmin, + userHasCapability, + userIsAdmin, +} from './capabilities.server' + +// ============================================================================ +// OAuth Utilities +// ============================================================================ + +export { + buildGitHubAuthUrl, + buildGoogleAuthUrl, + exchangeGitHubCode, + exchangeGoogleCode, + fetchGitHubProfile, + fetchGoogleProfile, +} from './oauth.server' + +// ============================================================================ +// Guards +// ============================================================================ + +export { createAuthGuards, withCapability, withAuth } from './guards.server' +export type { AuthGuards } from './guards.server' + +// ============================================================================ +// Repositories (for custom implementations) +// ============================================================================ + +export { + DrizzleUserRepository, + DrizzleOAuthAccountRepository, + DrizzleCapabilitiesRepository, + createRepositories, +} from './repositories.server' + +// ============================================================================ +// Context & Service Accessors (Application-specific) +// ============================================================================ + +export { + getAuthContext, + getAuthService, + getSessionService, + getCapabilitiesService, + getOAuthService, + getAuthGuards, + getUserRepository, + getOAuthAccountRepository, + getCapabilitiesRepository, +} from './context.server' diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 00000000..9191a246 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,57 @@ +/** + * Auth Module - Client Entry Point + * + * This is the main entry point for client-side auth functionality. + * Import from '~/auth' for client-side code. + * + * For server-side code, import from '~/auth/index.server' instead. + * + * Example usage: + * + * ```tsx + * import { authClient, navigateToSignIn } from '~/auth' + * + * // Sign in with GitHub + * authClient.signIn.social({ provider: 'github' }) + * + * // Sign out + * authClient.signOut() + * ``` + */ + +// ============================================================================ +// Types (client-safe types only) +// ============================================================================ + +export type { + Capability, + OAuthProvider, + AuthUser, + AuthErrorCode, +} from './types' + +export { VALID_CAPABILITIES, AuthError } from './types' + +// ============================================================================ +// Client Auth +// ============================================================================ + +export { + authClient, + navigateToSignIn, + navigateToSignOut, + getCurrentPath, +} from './client' + +// ============================================================================ +// Capability Utilities (client-safe) +// ============================================================================ + +export { + hasCapability, + hasAllCapabilities, + hasAnyCapability, + isAdmin, + userHasCapability, + userIsAdmin, +} from './capabilities.server' diff --git a/src/auth/oauth.server.ts b/src/auth/oauth.server.ts new file mode 100644 index 00000000..25441083 --- /dev/null +++ b/src/auth/oauth.server.ts @@ -0,0 +1,379 @@ +/** + * OAuth Service + * + * Handles OAuth account management and user creation/linking. + * Uses inversion of control for database access. + */ + +import type { + IOAuthService, + IOAuthAccountRepository, + IUserRepository, + OAuthProfile, + OAuthProvider, + OAuthResult, +} from './types' +import { AuthError } from './types' + +// ============================================================================ +// OAuth Service Implementation +// ============================================================================ + +export class OAuthService implements IOAuthService { + constructor( + private oauthAccountRepository: IOAuthAccountRepository, + private userRepository: IUserRepository, + ) {} + + /** + * Upsert OAuth account and associated user + * - If OAuth account exists, updates user info and returns existing user + * - If user with email exists, links OAuth account to existing user + * - Otherwise, creates new user and OAuth account + */ + async upsertOAuthAccount( + provider: OAuthProvider, + profile: OAuthProfile, + ): Promise { + try { + // Check if OAuth account already exists + const existingAccount = + await this.oauthAccountRepository.findByProviderAndAccountId( + provider, + profile.id, + ) + + if (existingAccount) { + // Account exists, update user info if needed + const user = await this.userRepository.findById(existingAccount.userId) + + if (!user) { + console.error( + `[OAuthService] OAuth account exists for ${provider}:${profile.id} but user ${existingAccount.userId} not found`, + ) + throw new AuthError( + 'User not found for existing OAuth account', + 'USER_NOT_FOUND', + ) + } + + const updates: { + email?: string + name?: string + image?: string + updatedAt?: Date + } = {} + + if (profile.email && user.email !== profile.email) { + updates.email = profile.email + } + if (profile.name && user.name !== profile.name) { + updates.name = profile.name + } + if (profile.image && user.image !== profile.image) { + updates.image = profile.image + } + + if (Object.keys(updates).length > 0) { + updates.updatedAt = new Date() + await this.userRepository.update(existingAccount.userId, updates) + } + + return { + userId: existingAccount.userId, + isNewUser: false, + } + } + + // Find user by email (for linking multiple OAuth providers) + const existingUser = await this.userRepository.findByEmail(profile.email) + + let userId: string + + if (existingUser) { + // Link OAuth account to existing user + console.log( + `[OAuthService] Linking ${provider} account to existing user ${existingUser.id} (${profile.email})`, + ) + userId = existingUser.id + + // Update user info if provided and not already set + const updates: { + name?: string + image?: string + updatedAt?: Date + } = {} + + if (profile.name && !existingUser.name) { + updates.name = profile.name + } + if (profile.image && !existingUser.image) { + updates.image = profile.image + } + + if (Object.keys(updates).length > 0) { + updates.updatedAt = new Date() + await this.userRepository.update(userId, updates) + } + } else { + // Create new user + console.log( + `[OAuthService] Creating new user for ${provider} login: ${profile.email}`, + ) + const newUser = await this.userRepository.create({ + email: profile.email, + name: profile.name, + image: profile.image, + displayUsername: profile.name, + capabilities: [], + }) + + userId = newUser.id + } + + // Create OAuth account link + await this.oauthAccountRepository.create({ + userId, + provider, + providerAccountId: profile.id, + email: profile.email, + }) + + return { + userId, + isNewUser: !existingUser, + } + } catch (error) { + if (error instanceof AuthError) { + throw error + } + + console.error( + `[OAuthService] Failed to upsert OAuth account for ${provider}:${profile.id} (${profile.email}):`, + { + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + }, + ) + throw new AuthError( + `OAuth account creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'OAUTH_ERROR', + ) + } + } +} + +// ============================================================================ +// OAuth Provider Utilities +// ============================================================================ + +export interface OAuthConfig { + clientId: string + clientSecret: string +} + +export interface GitHubOAuthConfig extends OAuthConfig {} +export interface GoogleOAuthConfig extends OAuthConfig {} + +/** + * Build GitHub OAuth authorization URL + */ +export function buildGitHubAuthUrl( + clientId: string, + redirectUri: string, + state: string, +): string { + return `https://github.com/login/oauth/authorize?client_id=${encodeURIComponent( + clientId, + )}&redirect_uri=${encodeURIComponent( + redirectUri, + )}&scope=user:email&state=${state}` +} + +/** + * Build Google OAuth authorization URL + */ +export function buildGoogleAuthUrl( + clientId: string, + redirectUri: string, + state: string, +): string { + return `https://accounts.google.com/o/oauth2/v2/auth?client_id=${encodeURIComponent( + clientId, + )}&redirect_uri=${encodeURIComponent( + redirectUri, + )}&response_type=code&scope=openid email profile&state=${state}` +} + +/** + * Exchange GitHub authorization code for access token + */ +export async function exchangeGitHubCode( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise { + const tokenResponse = await fetch( + 'https://github.com/login/oauth/access_token', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }, + ) + + const tokenData = await tokenResponse.json() + if (tokenData.error) { + console.error( + `[OAuth] GitHub token exchange failed: ${tokenData.error}, description: ${tokenData.error_description || 'none'}`, + ) + throw new AuthError(`GitHub OAuth error: ${tokenData.error}`, 'OAUTH_ERROR') + } + + if (!tokenData.access_token) { + console.error( + '[OAuth] GitHub token exchange succeeded but no access_token returned', + ) + throw new AuthError( + 'No access token received from GitHub', + 'OAUTH_ERROR', + ) + } + + return tokenData.access_token +} + +/** + * Fetch GitHub user profile + */ +export async function fetchGitHubProfile( + accessToken: string, +): Promise { + const profileResponse = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }) + + const profile = await profileResponse.json() + + // Fetch email (may require separate call) + let email = profile.email + if (!email) { + const emailResponse = await fetch('https://api.github.com/user/emails', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }) + const emails = await emailResponse.json() + const primaryEmail = emails.find( + (e: { primary: boolean; verified: boolean; email: string }) => + e.primary && e.verified, + ) + const verifiedEmail = emails.find( + (e: { verified: boolean; email: string }) => e.verified, + ) + email = primaryEmail?.email || verifiedEmail?.email + } + + if (!email) { + console.error( + `[OAuth] No verified email found for GitHub user ${profile.id} (${profile.login})`, + ) + throw new AuthError( + 'No verified email found for GitHub account', + 'OAUTH_ERROR', + ) + } + + return { + id: String(profile.id), + email, + name: profile.name || profile.login, + image: profile.avatar_url, + } +} + +/** + * Exchange Google authorization code for access token + */ +export async function exchangeGoogleCode( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise { + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }) + + const tokenData = await tokenResponse.json() + if (tokenData.error) { + console.error( + `[OAuth] Google token exchange failed: ${tokenData.error}, description: ${tokenData.error_description || 'none'}`, + ) + throw new AuthError(`Google OAuth error: ${tokenData.error}`, 'OAUTH_ERROR') + } + + if (!tokenData.access_token) { + console.error( + '[OAuth] Google token exchange succeeded but no access_token returned', + ) + throw new AuthError('No access token received from Google', 'OAUTH_ERROR') + } + + return tokenData.access_token +} + +/** + * Fetch Google user profile + */ +export async function fetchGoogleProfile( + accessToken: string, +): Promise { + const profileResponse = await fetch( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + + const profile = await profileResponse.json() + + if (!profile.verified_email) { + console.error( + `[OAuth] Google email not verified for user ${profile.id} (${profile.email})`, + ) + throw new AuthError('Google email not verified', 'OAUTH_ERROR') + } + + return { + id: profile.id, + email: profile.email, + name: profile.name, + image: profile.picture, + } +} diff --git a/src/auth/repositories.server.ts b/src/auth/repositories.server.ts new file mode 100644 index 00000000..e79ab33f --- /dev/null +++ b/src/auth/repositories.server.ts @@ -0,0 +1,275 @@ +/** + * Auth Repositories + * + * Implementation of repository interfaces using the application's database. + * This is the bridge between the auth module and the actual data layer. + * + * Note: This file imports from the application's database, making it + * application-specific. The auth module itself (types, services) remains + * database-agnostic through the repository interfaces. + */ + +import { db } from '~/db/client' +import { users, oauthAccounts, roles, roleAssignments } from '~/db/schema' +import { eq, and, inArray } from 'drizzle-orm' +import type { + Capability, + DbUser, + ICapabilitiesRepository, + IOAuthAccountRepository, + IUserRepository, + OAuthProvider, +} from './types' + +// ============================================================================ +// User Repository Implementation +// ============================================================================ + +export class DrizzleUserRepository implements IUserRepository { + async findById(userId: string): Promise { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }) + + if (!user) return null + + return this.mapToDbUser(user) + } + + async findByEmail(email: string): Promise { + const user = await db.query.users.findFirst({ + where: eq(users.email, email), + }) + + if (!user) return null + + return this.mapToDbUser(user) + } + + async create(data: { + email: string + name?: string + image?: string + displayUsername?: string + capabilities?: Capability[] + }): Promise { + const [newUser] = await db + .insert(users) + .values({ + email: data.email, + name: data.name, + image: data.image, + displayUsername: data.displayUsername, + capabilities: data.capabilities || [], + }) + .returning() + + if (!newUser) { + throw new Error('Failed to create user') + } + + return this.mapToDbUser(newUser) + } + + async update( + userId: string, + data: Partial<{ + email: string + name: string + image: string + displayUsername: string + capabilities: Capability[] + adsDisabled: boolean + interestedInHidingAds: boolean + sessionVersion: number + updatedAt: Date + }>, + ): Promise { + await db + .update(users) + .set({ ...data, updatedAt: data.updatedAt || new Date() }) + .where(eq(users.id, userId)) + } + + async incrementSessionVersion(userId: string): Promise { + // Get current version first + const user = await this.findById(userId) + if (user) { + await db + .update(users) + .set({ + sessionVersion: user.sessionVersion + 1, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)) + } + } + + private mapToDbUser(user: typeof users.$inferSelect): DbUser { + return { + id: user.id, + email: user.email, + name: user.name, + image: user.image, + displayUsername: user.displayUsername, + capabilities: user.capabilities as Capability[], + adsDisabled: user.adsDisabled, + interestedInHidingAds: user.interestedInHidingAds, + sessionVersion: user.sessionVersion, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } + } +} + +// ============================================================================ +// OAuth Account Repository Implementation +// ============================================================================ + +export class DrizzleOAuthAccountRepository implements IOAuthAccountRepository { + async findByProviderAndAccountId( + provider: OAuthProvider, + providerAccountId: string, + ): Promise<{ userId: string } | null> { + const account = await db.query.oauthAccounts.findFirst({ + where: and( + eq(oauthAccounts.provider, provider), + eq(oauthAccounts.providerAccountId, providerAccountId), + ), + }) + + if (!account) return null + + return { userId: account.userId } + } + + async create(data: { + userId: string + provider: OAuthProvider + providerAccountId: string + email: string + }): Promise { + await db.insert(oauthAccounts).values({ + userId: data.userId, + provider: data.provider, + providerAccountId: data.providerAccountId, + email: data.email, + }) + } +} + +// ============================================================================ +// Capabilities Repository Implementation +// ============================================================================ + +export class DrizzleCapabilitiesRepository implements ICapabilitiesRepository { + async getEffectiveCapabilities(userId: string): Promise { + // Single query to get both user capabilities and role capabilities + const result = await db + .select({ + userCapabilities: users.capabilities, + roleCapabilities: roles.capabilities, + }) + .from(users) + .leftJoin(roleAssignments, eq(roleAssignments.userId, users.id)) + .leftJoin(roles, eq(roles.id, roleAssignments.roleId)) + .where(eq(users.id, userId)) + + if (result.length === 0) { + return [] + } + + // Extract user capabilities (same for all rows) + const directCapabilities = (result[0]?.userCapabilities || []) as Capability[] + + // Collect all role capabilities from all rows + const roleCapabilities = result + .map((r) => r.roleCapabilities) + .filter( + (caps): caps is Capability[] => caps !== null && Array.isArray(caps), + ) + .flat() as Capability[] + + // Union of direct capabilities and role capabilities + const effectiveCapabilities = Array.from( + new Set([...directCapabilities, ...roleCapabilities]), + ) + + return effectiveCapabilities + } + + async getBulkEffectiveCapabilities( + userIds: string[], + ): Promise> { + if (userIds.length === 0) { + return {} + } + + // Single query to get all user capabilities and role capabilities for all users + const result = await db + .select({ + userId: users.id, + userCapabilities: users.capabilities, + roleCapabilities: roles.capabilities, + }) + .from(users) + .leftJoin(roleAssignments, eq(roleAssignments.userId, users.id)) + .leftJoin(roles, eq(roles.id, roleAssignments.roleId)) + .where(inArray(users.id, userIds)) + + // Group results by userId + const userCapabilitiesMap: Record = {} + const userRoleCapabilitiesMap: Record = {} + + for (const row of result) { + const userId = row.userId + + // Store direct capabilities (same for all rows of the same user) + if (!userCapabilitiesMap[userId]) { + userCapabilitiesMap[userId] = (row.userCapabilities || []) as Capability[] + } + + // Collect role capabilities + if (row.roleCapabilities && Array.isArray(row.roleCapabilities)) { + if (!userRoleCapabilitiesMap[userId]) { + userRoleCapabilitiesMap[userId] = [] + } + userRoleCapabilitiesMap[userId].push( + ...(row.roleCapabilities as Capability[]), + ) + } + } + + // Compute effective capabilities for each user + const effectiveCapabilitiesMap: Record = {} + + for (const userId of userIds) { + const directCapabilities = userCapabilitiesMap[userId] || [] + const roleCapabilities = userRoleCapabilitiesMap[userId] || [] + + // Union of direct capabilities and role capabilities + const effectiveCapabilities = Array.from( + new Set([...directCapabilities, ...roleCapabilities]), + ) + + effectiveCapabilitiesMap[userId] = effectiveCapabilities + } + + return effectiveCapabilitiesMap + } +} + +// ============================================================================ +// Repository Factory +// ============================================================================ + +/** + * Create all repository instances + */ +export function createRepositories() { + return { + userRepository: new DrizzleUserRepository(), + oauthAccountRepository: new DrizzleOAuthAccountRepository(), + capabilitiesRepository: new DrizzleCapabilitiesRepository(), + } +} diff --git a/src/auth/session.server.ts b/src/auth/session.server.ts new file mode 100644 index 00000000..4f23d397 --- /dev/null +++ b/src/auth/session.server.ts @@ -0,0 +1,272 @@ +/** + * Session Management Module + * + * Handles cookie-based session management with HMAC-SHA256 signing. + * This module is framework-agnostic and uses Web Crypto API. + */ + +import type { SessionCookieData, ISessionService } from './types' + +// ============================================================================ +// Base64URL Utilities +// ============================================================================ + +function base64UrlEncode(str: string): string { + if (typeof btoa !== 'undefined') { + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') + } + + const encoder = new TextEncoder() + const bytes = encoder.encode(str) + const base64 = bytesToBase64(bytes) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +function base64UrlDecode(str: string): string { + const normalized = str.replace(/-/g, '+').replace(/_/g, '/') + + if (typeof atob !== 'undefined') { + return atob(normalized) + } + + const bytes = base64ToBytes(normalized) + const decoder = new TextDecoder() + return decoder.decode(bytes) +} + +function bytesToBase64(bytes: Uint8Array): string { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + let result = '' + let i = 0 + + while (i < bytes.length) { + const a = bytes[i++] + const b = i < bytes.length ? bytes[i++] : 0 + const c = i < bytes.length ? bytes[i++] : 0 + + const bitmap = (a << 16) | (b << 8) | c + + result += chars.charAt((bitmap >> 18) & 63) + result += chars.charAt((bitmap >> 12) & 63) + result += i - 2 < bytes.length ? chars.charAt((bitmap >> 6) & 63) : '=' + result += i - 1 < bytes.length ? chars.charAt(bitmap & 63) : '=' + } + + return result +} + +function base64ToBytes(base64: string): Uint8Array { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + const lookup = new Map() + for (let i = 0; i < chars.length; i++) { + lookup.set(chars[i], i) + } + + base64 = base64.replace(/=+$/, '') + const bytes: number[] = [] + + for (let i = 0; i < base64.length; i += 4) { + const enc1 = lookup.get(base64[i]) ?? 0 + const enc2 = lookup.get(base64[i + 1]) ?? 0 + const enc3 = lookup.get(base64[i + 2]) ?? 0 + const enc4 = lookup.get(base64[i + 3]) ?? 0 + + const bitmap = (enc1 << 18) | (enc2 << 12) | (enc3 << 6) | enc4 + + bytes.push((bitmap >> 16) & 255) + if (enc3 !== 64) bytes.push((bitmap >> 8) & 255) + if (enc4 !== 64) bytes.push(bitmap & 255) + } + + return new Uint8Array(bytes) +} + +// ============================================================================ +// Session Service Implementation +// ============================================================================ + +export class SessionService implements ISessionService { + private secret: string + private isProduction: boolean + + constructor(secret: string, isProduction: boolean = false) { + if (isProduction && secret === 'dev-secret-key-change-in-production') { + throw new Error('SESSION_SECRET must be set in production') + } + this.secret = secret + this.isProduction = isProduction + } + + /** + * Sign cookie data using HMAC-SHA256 + */ + async signCookie(data: SessionCookieData): Promise { + const payload = `${data.userId}:${data.expiresAt}:${data.version}` + const payloadBase64 = base64UrlEncode(payload) + + const encoder = new TextEncoder() + const keyData = encoder.encode(this.secret) + const messageData = encoder.encode(payloadBase64) + + const key = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + + const signature = await crypto.subtle.sign('HMAC', key, messageData) + const signatureArray = new Uint8Array(signature) + + let signatureStr = '' + for (let i = 0; i < signatureArray.length; i++) { + signatureStr += String.fromCharCode(signatureArray[i]) + } + const signatureBase64 = base64UrlEncode(signatureStr) + + return `${payloadBase64}.${signatureBase64}` + } + + /** + * Verify and parse signed cookie + */ + async verifyCookie(signedCookie: string): Promise { + try { + const [payloadBase64, signatureBase64] = signedCookie.split('.') + + if (!payloadBase64 || !signatureBase64) { + return null + } + + const encoder = new TextEncoder() + const keyData = encoder.encode(this.secret) + const messageData = encoder.encode(payloadBase64) + + const key = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ) + + const signatureStr = base64UrlDecode(signatureBase64) + const signature = Uint8Array.from(signatureStr, (c) => c.charCodeAt(0)) + + const isValid = await crypto.subtle.verify( + 'HMAC', + key, + signature, + messageData, + ) + + if (!isValid) { + return null + } + + const payload = base64UrlDecode(payloadBase64) + const [userId, expiresAtStr, versionStr] = payload.split(':') + + if (!userId || !expiresAtStr || !versionStr) { + return null + } + + const expiresAt = parseInt(expiresAtStr, 10) + const version = parseInt(versionStr, 10) + + // Check expiration + if (Date.now() > expiresAt) { + return null + } + + return { + userId, + expiresAt, + version, + } + } catch (error) { + console.error( + '[SessionService] Error verifying cookie:', + error instanceof Error ? error.message : 'Unknown error', + ) + return null + } + } + + /** + * Read session cookie from request + */ + getSessionCookie(request: Request): string | null { + const cookies = request.headers.get('cookie') || '' + const sessionCookie = cookies + .split(';') + .find((c) => c.trim().startsWith('session_token=')) + + if (!sessionCookie) { + return null + } + + const tokenValue = sessionCookie.split('=').slice(1).join('=').trim() + const sessionToken = decodeURIComponent(tokenValue) + return sessionToken || null + } + + /** + * Create session cookie header value + */ + createSessionCookieHeader(signedCookie: string, maxAge: number): string { + return `session_token=${encodeURIComponent(signedCookie)}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax${this.isProduction ? '; Secure' : ''}` + } + + /** + * Create clear session cookie header value + */ + createClearSessionCookieHeader(): string { + return `session_token=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${this.isProduction ? '; Secure' : ''}` + } +} + +// ============================================================================ +// OAuth State Cookie Utilities +// ============================================================================ + +export function generateOAuthState(): string { + const stateBytes = new Uint8Array(16) + crypto.getRandomValues(stateBytes) + const base64 = btoa(String.fromCharCode(...stateBytes)) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +export function createOAuthStateCookie( + state: string, + isProduction: boolean, +): string { + return `oauth_state=${encodeURIComponent(state)}; HttpOnly; Path=/; Max-Age=${10 * 60}; SameSite=Lax${isProduction ? '; Secure' : ''}` +} + +export function clearOAuthStateCookie(isProduction: boolean): string { + return `oauth_state=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${isProduction ? '; Secure' : ''}` +} + +export function getOAuthStateCookie(request: Request): string | null { + const cookies = request.headers.get('cookie') || '' + const stateCookie = cookies + .split(';') + .find((c) => c.trim().startsWith('oauth_state=')) + + if (!stateCookie) { + return null + } + + return decodeURIComponent(stateCookie.split('=').slice(1).join('=').trim()) +} + +// ============================================================================ +// Session Constants +// ============================================================================ + +export const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000 // 30 days +export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 // 30 days diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 00000000..2ceb33c1 --- /dev/null +++ b/src/auth/types.ts @@ -0,0 +1,224 @@ +/** + * Auth Module Types + * + * This file defines the core types and interfaces for the authentication module. + * These types are designed to be framework-agnostic and can be used by both + * server and client code. + */ + +// ============================================================================ +// Capability Types +// ============================================================================ + +export type Capability = + | 'admin' + | 'disableAds' + | 'builder' + | 'feed' + | 'moderate-feedback' + +export const VALID_CAPABILITIES: readonly Capability[] = [ + 'admin', + 'disableAds', + 'builder', + 'feed', + 'moderate-feedback', +] as const + +// ============================================================================ +// OAuth Types +// ============================================================================ + +export type OAuthProvider = 'github' | 'google' + +export interface OAuthProfile { + id: string + email: string + name?: string + image?: string +} + +export interface OAuthResult { + userId: string + isNewUser: boolean +} + +// ============================================================================ +// Session Types +// ============================================================================ + +export interface SessionCookieData { + userId: string + expiresAt: number // Unix timestamp in milliseconds + version: number // sessionVersion from users table for revocation +} + +// ============================================================================ +// User Types +// ============================================================================ + +/** + * Authenticated user data returned from session validation + */ +export interface AuthUser { + userId: string + email: string + name: string | null + image: string | null + displayUsername: string | null + capabilities: Capability[] + adsDisabled: boolean | null + interestedInHidingAds: boolean | null +} + +/** + * Database user record (used by data access layer) + */ +export interface DbUser { + id: string + email: string + name: string | null + image: string | null + displayUsername: string | null + capabilities: Capability[] + adsDisabled: boolean | null + interestedInHidingAds: boolean | null + sessionVersion: number + createdAt: Date + updatedAt: Date +} + +// ============================================================================ +// Data Access Interfaces (for Inversion of Control) +// ============================================================================ + +/** + * User repository interface for database operations + * Implement this interface to inject database access into the auth module + */ +export interface IUserRepository { + findById(userId: string): Promise + findByEmail(email: string): Promise + create(data: { + email: string + name?: string + image?: string + displayUsername?: string + capabilities?: Capability[] + }): Promise + update( + userId: string, + data: Partial<{ + email: string + name: string + image: string + displayUsername: string + capabilities: Capability[] + adsDisabled: boolean + interestedInHidingAds: boolean + sessionVersion: number + updatedAt: Date + }>, + ): Promise + incrementSessionVersion(userId: string): Promise +} + +/** + * OAuth account repository interface + */ +export interface IOAuthAccountRepository { + findByProviderAndAccountId( + provider: OAuthProvider, + providerAccountId: string, + ): Promise<{ userId: string } | null> + create(data: { + userId: string + provider: OAuthProvider + providerAccountId: string + email: string + }): Promise +} + +/** + * Capabilities repository interface + */ +export interface ICapabilitiesRepository { + getEffectiveCapabilities(userId: string): Promise + getBulkEffectiveCapabilities( + userIds: string[], + ): Promise> +} + +// ============================================================================ +// Service Interfaces +// ============================================================================ + +/** + * Session service interface for cookie-based session management + */ +export interface ISessionService { + signCookie(data: SessionCookieData): Promise + verifyCookie(signedCookie: string): Promise + getSessionCookie(request: Request): string | null + createSessionCookieHeader(signedCookie: string, maxAge: number): string + createClearSessionCookieHeader(): string +} + +/** + * Auth service interface for user authentication + */ +export interface IAuthService { + getCurrentUser(request: Request): Promise + validateSession( + sessionData: SessionCookieData, + ): Promise<{ user: DbUser; capabilities: Capability[] } | null> +} + +/** + * OAuth service interface for OAuth operations + */ +export interface IOAuthService { + upsertOAuthAccount( + provider: OAuthProvider, + profile: OAuthProfile, + ): Promise +} + +// ============================================================================ +// Auth Context (Dependency Injection Container) +// ============================================================================ + +/** + * Auth context contains all dependencies required by the auth module + * Use this to inject implementations at runtime + */ +export interface AuthContext { + userRepository: IUserRepository + oauthAccountRepository: IOAuthAccountRepository + capabilitiesRepository: ICapabilitiesRepository + sessionSecret: string + isProduction: boolean +} + +// ============================================================================ +// Error Types +// ============================================================================ + +export class AuthError extends Error { + constructor( + message: string, + public code: AuthErrorCode, + ) { + super(message) + this.name = 'AuthError' + } +} + +export type AuthErrorCode = + | 'NOT_AUTHENTICATED' + | 'MISSING_CAPABILITY' + | 'INVALID_SESSION' + | 'SESSION_EXPIRED' + | 'SESSION_REVOKED' + | 'USER_NOT_FOUND' + | 'OAUTH_ERROR' diff --git a/src/routes/api/auth/callback/$provider.tsx b/src/routes/api/auth/callback/$provider.tsx index 6fcc35bf..2760a179 100644 --- a/src/routes/api/auth/callback/$provider.tsx +++ b/src/routes/api/auth/callback/$provider.tsx @@ -1,17 +1,26 @@ import { createFileRoute } from '@tanstack/react-router' import { env } from '~/utils/env' -import { upsertOAuthAccount } from '~/utils/oauth.server' -import { signCookie } from '~/utils/cookies.server' -import { db } from '~/db/client' -import { users } from '~/db/schema' -import { eq } from 'drizzle-orm' +import { + getOAuthStateCookie, + clearOAuthStateCookie, + getSessionService, + getOAuthService, + getUserRepository, + exchangeGitHubCode, + exchangeGoogleCode, + fetchGitHubProfile, + fetchGoogleProfile, + SESSION_DURATION_MS, + SESSION_MAX_AGE_SECONDS, +} from '~/auth/index.server' export const Route = createFileRoute('/api/auth/callback/$provider')({ server: { handlers: { GET: async ({ request, params }) => { - try { + const isProduction = process.env.NODE_ENV === 'production' + try { const provider = params.provider as 'github' | 'google' const url = new URL(request.url) const code = url.searchParams.get('code') @@ -19,235 +28,148 @@ export const Route = createFileRoute('/api/auth/callback/$provider')({ const error = url.searchParams.get('error') if (error) { - console.error(`[AUTH:ERROR] OAuth error received from provider: ${error}`) - return Response.redirect(new URL('/login?error=oauth_failed', request.url), 302) + console.error( + `[AUTH:ERROR] OAuth error received from provider: ${error}`, + ) + return Response.redirect( + new URL('/login?error=oauth_failed', request.url), + 302, + ) } if (!code || !state) { - console.error('[AUTH:ERROR] Missing code or state in OAuth callback') - return Response.redirect(new URL('/login?error=oauth_failed', request.url), 302) + console.error( + '[AUTH:ERROR] Missing code or state in OAuth callback', + ) + return Response.redirect( + new URL('/login?error=oauth_failed', request.url), + 302, + ) } // Validate state from HTTPS-only cookie (CSRF protection) - const cookies = request.headers.get('cookie') || '' - const stateCookie = cookies - .split(';') - .find((c) => c.trim().startsWith('oauth_state=')) - - if (!stateCookie) { - console.error('[AUTH:ERROR] No state cookie found - possible CSRF or cookie issue') - return Response.redirect(new URL('/login?error=oauth_failed', request.url), 302) - } + const cookieState = getOAuthStateCookie(request) - const cookieState = decodeURIComponent(stateCookie.split('=').slice(1).join('=').trim()) + if (!cookieState) { + console.error( + '[AUTH:ERROR] No state cookie found - possible CSRF or cookie issue', + ) + return Response.redirect( + new URL('/login?error=oauth_failed', request.url), + 302, + ) + } if (cookieState !== state) { - console.error(`[AUTH:ERROR] State mismatch - expected: ${cookieState.substring(0, 10)}..., received: ${state.substring(0, 10)}...`) - return Response.redirect(new URL('/login?error=oauth_failed', request.url), 302) + console.error( + `[AUTH:ERROR] State mismatch - expected: ${cookieState.substring(0, 10)}..., received: ${state.substring(0, 10)}...`, + ) + return Response.redirect( + new URL('/login?error=oauth_failed', request.url), + 302, + ) } // Clear state cookie (one-time use) - const clearStateCookie = `oauth_state=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${process.env.NODE_ENV === 'production' ? '; Secure' : ''}` - - // Exchange code for access token - // Use SITE_URL env var if set, otherwise fall back to request origin - const origin = env.SITE_URL || new URL(request.url).origin - const redirectUri = `${origin}/api/auth/callback/${provider}` - let accessToken: string - let userProfile: { - id: string - email: string - name?: string - image?: string - } - - if (provider === 'github') { - const clientId = env.GITHUB_OAUTH_CLIENT_ID - const clientSecret = env.GITHUB_OAUTH_CLIENT_SECRET - if (!clientId || !clientSecret) { - throw new Error('GitHub OAuth credentials not configured') - } + const clearStateCookieHeader = clearOAuthStateCookie(isProduction) - // Exchange code for token - const tokenResponse = await fetch( - 'https://github.com/login/oauth/access_token', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri: redirectUri, - }), - } - ) + // Exchange code for access token + const origin = env.SITE_URL || new URL(request.url).origin + const redirectUri = `${origin}/api/auth/callback/${provider}` - const tokenData = await tokenResponse.json() - if (tokenData.error) { - console.error(`[AUTH:ERROR] GitHub token exchange failed: ${tokenData.error}, description: ${tokenData.error_description || 'none'}`) - throw new Error(`GitHub OAuth error: ${tokenData.error}`) + let userProfile: { + id: string + email: string + name?: string + image?: string } - if (!tokenData.access_token) { - console.error('[AUTH:ERROR] GitHub token exchange succeeded but no access_token returned') - throw new Error('No access token received from GitHub') - } - - accessToken = tokenData.access_token - // Fetch user profile - const profileResponse = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github.v3+json', - }, - }) + if (provider === 'github') { + const clientId = env.GITHUB_OAUTH_CLIENT_ID + const clientSecret = env.GITHUB_OAUTH_CLIENT_SECRET + if (!clientId || !clientSecret) { + throw new Error('GitHub OAuth credentials not configured') + } - const profile = await profileResponse.json() - - // Fetch email (may require separate call) - let email = profile.email - if (!email) { - const emailResponse = await fetch( - 'https://api.github.com/user/emails', - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github.v3+json', - }, - } + const accessToken = await exchangeGitHubCode( + code, + clientId, + clientSecret, + redirectUri, ) - const emails = await emailResponse.json() - const primaryEmail = emails.find((e: any) => e.primary && e.verified) - const verifiedEmail = emails.find((e: any) => e.verified) - email = primaryEmail?.email || verifiedEmail?.email - } - - if (!email) { - console.error(`[AUTH:ERROR] No verified email found for GitHub user ${profile.id} (${profile.login})`) - throw new Error('No verified email found for GitHub account') - } - - userProfile = { - id: String(profile.id), - email: email, - name: profile.name || profile.login, - image: profile.avatar_url, - } - } else { - // Google - const clientId = env.GOOGLE_OAUTH_CLIENT_ID - const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET - if (!clientId || !clientSecret) { - throw new Error('Google OAuth credentials not configured') - } + userProfile = await fetchGitHubProfile(accessToken) + } else { + // Google + const clientId = env.GOOGLE_OAUTH_CLIENT_ID + const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET + if (!clientId || !clientSecret) { + throw new Error('Google OAuth credentials not configured') + } - // Exchange code for token - const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, + const accessToken = await exchangeGoogleCode( code, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }), - }) - - const tokenData = await tokenResponse.json() - if (tokenData.error) { - console.error(`[AUTH:ERROR] Google token exchange failed: ${tokenData.error}, description: ${tokenData.error_description || 'none'}`) - throw new Error(`Google OAuth error: ${tokenData.error}`) - } - - if (!tokenData.access_token) { - console.error('[AUTH:ERROR] Google token exchange succeeded but no access_token returned') - throw new Error('No access token received from Google') + clientId, + clientSecret, + redirectUri, + ) + userProfile = await fetchGoogleProfile(accessToken) } - accessToken = tokenData.access_token - - // Fetch user profile - const profileResponse = await fetch( - 'https://www.googleapis.com/oauth2/v2/userinfo', - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } + // Upsert user and OAuth account + const oauthService = getOAuthService() + const result = await oauthService.upsertOAuthAccount( + provider, + userProfile, ) - const profile = await profileResponse.json() - - if (!profile.verified_email) { - console.error(`[AUTH:ERROR] Google email not verified for user ${profile.id} (${profile.email})`) - throw new Error('Google email not verified') - } - - userProfile = { - id: profile.id, - email: profile.email, - name: profile.name, - image: profile.picture, + // Get user to access sessionVersion + const userRepository = getUserRepository() + const user = await userRepository.findById(result.userId) + + if (!user) { + console.error( + `[AUTH:ERROR] User ${result.userId} not found after OAuth account creation for ${provider}:${userProfile.id} (${userProfile.email})`, + ) + throw new Error('User not found after OAuth account creation') } - } - // Upsert user and OAuth account - const result = await upsertOAuthAccount(provider, userProfile) + // Create signed session cookie + const sessionService = getSessionService() + const expiresAt = Date.now() + SESSION_DURATION_MS + const signedCookie = await sessionService.signCookie({ + userId: user.id, + expiresAt, + version: user.sessionVersion, + }) - // Get user to access sessionVersion - const user = await db.query.users.findFirst({ - where: eq(users.id, result.userId), - }) + const sessionCookie = sessionService.createSessionCookieHeader( + signedCookie, + SESSION_MAX_AGE_SECONDS, + ) - if (!user) { - console.error(`[AUTH:ERROR] User ${result.userId} not found after OAuth account creation for ${provider}:${userProfile.id} (${userProfile.email})`) - throw new Error('User not found after OAuth account creation') - } + // Return Response with Set-Cookie headers and redirect + const accountUrl = new URL('/account', request.url).toString() + const headers = new Headers() + headers.set('Location', accountUrl) + headers.append('Set-Cookie', clearStateCookieHeader) + headers.append('Set-Cookie', sessionCookie) - // Create signed cookie (30 days expiration) - const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000 - const signedCookie = await signCookie({ - userId: user.id, - expiresAt, - version: user.sessionVersion, - }) - - // Set session cookie (30 days, HTTP-only, Secure in prod) - // Note: Domain is omitted to allow cookie to work on localhost and production - // URL-encode the cookie value to handle any special characters - const sessionCookie = `session_token=${encodeURIComponent(signedCookie)}; HttpOnly; Path=/; Max-Age=${ - 30 * 24 * 60 * 60 - }; SameSite=Lax${process.env.NODE_ENV === 'production' ? '; Secure' : ''}` - - // Return Response with Set-Cookie headers and redirect - // Clear state cookie and set session cookie - const accountUrl = new URL('/account', request.url).toString() - const headers = new Headers() - headers.set('Location', accountUrl) - headers.append('Set-Cookie', clearStateCookie) - headers.append('Set-Cookie', sessionCookie) - - return new Response(null, { - status: 302, - headers, - }) + return new Response(null, { + status: 302, + headers, + }) } catch (err) { console.error('[AUTH:ERROR] OAuth callback failed:', { error: err instanceof Error ? err.message : 'Unknown error', stack: err instanceof Error ? err.stack : undefined, provider: params.provider, }) - return Response.redirect(new URL('/login?error=oauth_failed', request.url), 302) + return Response.redirect( + new URL('/login?error=oauth_failed', request.url), + 302, + ) } }, }, }, }) - diff --git a/src/routes/auth/$provider/start.tsx b/src/routes/auth/$provider/start.tsx index a1dc6195..60d716d1 100644 --- a/src/routes/auth/$provider/start.tsx +++ b/src/routes/auth/$provider/start.tsx @@ -1,5 +1,11 @@ import { createFileRoute } from '@tanstack/react-router' import { env } from '~/utils/env' +import { + generateOAuthState, + createOAuthStateCookie, + buildGitHubAuthUrl, + buildGoogleAuthUrl, +} from '~/auth/index.server' export const Route = createFileRoute('/auth/$provider/start')({ server: { @@ -11,52 +17,32 @@ export const Route = createFileRoute('/auth/$provider/start')({ return Response.redirect(new URL('/login', request.url), 302) } - // Generate random state token (16 bytes = 128 bits) - const stateBytes = new Uint8Array(16) - crypto.getRandomValues(stateBytes) - // Convert to base64url without using Buffer (browser-compatible) - const base64 = btoa(String.fromCharCode(...stateBytes)) - const state = base64 - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, '') + // Generate random state token for CSRF protection + const state = generateOAuthState() // Store state in HTTPS-only cookie for CSRF protection - // SameSite=Lax allows the cookie to be sent on OAuth redirects (top-level navigations) - // while still protecting against CSRF on POST requests - const stateCookie = `oauth_state=${encodeURIComponent( - state, - )}; HttpOnly; Path=/; Max-Age=${10 * 60}; SameSite=Lax${ - process.env.NODE_ENV === 'production' ? '; Secure' : '' - }` + const isProduction = process.env.NODE_ENV === 'production' + const stateCookie = createOAuthStateCookie(state, isProduction) // Build OAuth URL based on provider - let authUrl: string - // Use SITE_URL env var if set, otherwise fall back to request origin const origin = env.SITE_URL || new URL(request.url).origin const redirectUri = `${origin}/api/auth/callback/${provider}` + let authUrl: string + if (provider === 'github') { const clientId = env.GITHUB_OAUTH_CLIENT_ID if (!clientId) { throw new Error('GITHUB_OAUTH_CLIENT_ID is not configured') } - authUrl = `https://github.com/login/oauth/authorize?client_id=${encodeURIComponent( - clientId, - )}&redirect_uri=${encodeURIComponent( - redirectUri, - )}&scope=user:email&state=${state}` + authUrl = buildGitHubAuthUrl(clientId, redirectUri, state) } else { // Google const clientId = env.GOOGLE_OAUTH_CLIENT_ID if (!clientId) { throw new Error('GOOGLE_OAUTH_CLIENT_ID is not configured') } - authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${encodeURIComponent( - clientId, - )}&redirect_uri=${encodeURIComponent( - redirectUri, - )}&response_type=code&scope=openid email profile&state=${state}` + authUrl = buildGoogleAuthUrl(clientId, redirectUri, state) } // Return redirect with state cookie set diff --git a/src/routes/auth/signout.tsx b/src/routes/auth/signout.tsx index 09d451e4..552051e1 100644 --- a/src/routes/auth/signout.tsx +++ b/src/routes/auth/signout.tsx @@ -1,21 +1,23 @@ import { createFileRoute } from '@tanstack/react-router' -import { getSessionCookie, verifyCookie } from '~/utils/cookies.server' -import { revokeUserSessions } from '~/utils/users.server' +import { getSessionService, getUserRepository } from '~/auth/index.server' export const Route = createFileRoute('/auth/signout')({ server: { handlers: { GET: async ({ request }) => { + const sessionService = getSessionService() + const userRepository = getUserRepository() + // Read and verify signed cookie - const signedCookie = getSessionCookie(request) + const signedCookie = sessionService.getSessionCookie(request) if (signedCookie) { - const cookieData = await verifyCookie(signedCookie) + const cookieData = await sessionService.verifyCookie(signedCookie) // Revoke all sessions for this user (increment sessionVersion) if (cookieData) { try { - await revokeUserSessions({ data: { userId: cookieData.userId } }) + await userRepository.incrementSessionVersion(cookieData.userId) } catch (error) { // Log but don't fail if revocation fails console.error( @@ -27,8 +29,7 @@ export const Route = createFileRoute('/auth/signout')({ } // Clear session cookie - const clearCookie = - 'session_token=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax' + const clearCookie = sessionService.createClearSessionCookieHeader() // Return Response with Set-Cookie header and redirect const loginUrl = new URL('/login', request.url).toString() diff --git a/src/utils/auth.client.ts b/src/utils/auth.client.ts index b1cd6f3c..e762d955 100644 --- a/src/utils/auth.client.ts +++ b/src/utils/auth.client.ts @@ -1,11 +1,10 @@ -// Minimal auth client for OAuth flows -export const authClient = { - signIn: { - social: ({ provider }: { provider: 'github' | 'google' }) => { - window.location.href = `/auth/${provider}/start` - }, - }, - signOut: async () => { - window.location.href = '/auth/signout' - }, -} +/** + * Auth Client Utilities + * + * This module re-exports from the isolated auth module at ~/auth/ + * for backward compatibility with existing imports. + * + * For new code, import directly from '~/auth'. + */ + +export { authClient } from '~/auth/client' diff --git a/src/utils/auth.server-helpers.ts b/src/utils/auth.server-helpers.ts index 2b099d97..e5c2c174 100644 --- a/src/utils/auth.server-helpers.ts +++ b/src/utils/auth.server-helpers.ts @@ -1,78 +1,30 @@ +/** + * Auth Server Helpers + * + * This module delegates to the isolated auth module at ~/auth/ + * for backward compatibility with existing imports. + * + * For new code, import directly from '~/auth/index.server'. + */ + import { getRequest } from '@tanstack/react-start/server' -import { db } from '~/db/client' -import { users } from '~/db/schema' -import { eq } from 'drizzle-orm' -import { getSessionCookie, verifyCookie } from './cookies.server' -import { getEffectiveCapabilities } from './capabilities.server' +import { getAuthService, getSessionService } from '~/auth/index.server' -// Helper to get user from session cookie (server-side only) +/** + * Get current user from request + */ export async function getCurrentUserFromRequest(request: Request) { - const signedCookie = getSessionCookie(request) - - if (!signedCookie) { - // This is normal - user just isn't logged in - return null - } - - try { - // Verify and parse the signed cookie - const cookieData = await verifyCookie(signedCookie) - - if (!cookieData) { - console.error( - '[AUTH:ERROR] Session cookie verification failed - invalid signature or expired', - ) - return null - } - - // Query user from database - const user = await db.query.users.findFirst({ - where: eq(users.id, cookieData.userId), - }) - - if (!user) { - console.error( - `[AUTH:ERROR] Session cookie references non-existent user ${cookieData.userId}`, - ) - return null - } - - // Verify session version matches (for session revocation) - if (user.sessionVersion !== cookieData.version) { - console.error( - `[AUTH:ERROR] Session version mismatch for user ${user.id} - expected ${user.sessionVersion}, got ${cookieData.version}`, - ) - return null - } - - // Get effective capabilities (direct + role-based) - const capabilities = await getEffectiveCapabilities(user.id) - - // Return user with capabilities - return { - userId: user.id, - email: user.email, - name: user.name, - image: user.image, - displayUsername: user.displayUsername, - capabilities, - adsDisabled: user.adsDisabled, - interestedInHidingAds: user.interestedInHidingAds, - } - } catch (error) { - console.error('[AUTH:ERROR] Failed to get user from session:', { - error: error instanceof Error ? error.message : 'Unknown error', - stack: error instanceof Error ? error.stack : undefined, - }) - return null - } + const authService = getAuthService() + return authService.getCurrentUser(request) } -// Helper to get authenticated user from request (for use in server function wrappers) -// This uses getRequest() which is server-only, so this file should never be imported by client code +/** + * Get authenticated user from request (throws if not authenticated) + */ export async function getAuthenticatedUser() { const request = getRequest() - const user = await getCurrentUserFromRequest(request) + const authService = getAuthService() + const user = await authService.getCurrentUser(request) if (!user) { throw new Error('Not authenticated') @@ -81,11 +33,13 @@ export async function getAuthenticatedUser() { return user } -// Helper to get session token from request (for use in server function wrappers) -// This uses getRequest() which is server-only, so this file should never be imported by client code +/** + * Get session token from request + */ export function getSessionTokenFromRequest(): string { const request = getRequest() - const token = getSessionToken(request) + const sessionService = getSessionService() + const token = sessionService.getSessionCookie(request) if (!token) { throw new Error('Not authenticated') } diff --git a/src/utils/auth.server.ts b/src/utils/auth.server.ts index 9c70d0ef..425fbd5b 100644 --- a/src/utils/auth.server.ts +++ b/src/utils/auth.server.ts @@ -1,52 +1,52 @@ +/** + * Auth Server Utilities + * + * This module delegates to the isolated auth module at ~/auth/ + * for backward compatibility with existing imports. + * + * For new code, import directly from '~/auth/index.server'. + */ + import { createServerFn } from '@tanstack/react-start' import { getRequest } from '@tanstack/react-start/server' -import { getCurrentUserFromRequest } from './auth.server-helpers' -import type { Capability } from '~/db/schema' +import { getAuthService, getAuthGuards } from '~/auth/index.server' +import type { Capability } from '~/auth/index.server' -// Re-export getCurrentUser for backward compatibility +/** + * Server function to get the current user + */ export const getCurrentUser = createServerFn({ method: 'POST' }).handler( async () => { const request = getRequest() - return getCurrentUserFromRequest(request) + const authService = getAuthService() + return authService.getCurrentUser(request) }, ) -// Server function to require authentication +/** + * Server function to require authentication + */ export const requireAuth = createServerFn({ method: 'POST' }).handler( async () => { const request = getRequest() - const user = await getCurrentUserFromRequest(request) - if (!user) { - throw new Error('Not authenticated') - } - return user + const guards = getAuthGuards() + return guards.requireAuth(request) }, ) -// Server function to require a specific capability +/** + * Server function to require a specific capability + */ export const requireCapability = createServerFn({ method: 'POST' }) .inputValidator((data: { capability: string }) => ({ capability: data.capability as Capability, })) .handler(async ({ data: { capability } }) => { const request = getRequest() - const user = await getCurrentUserFromRequest(request) - if (!user) { - throw new Error('Not authenticated') - } - // Admin users have access to everything - const hasAccess = - user.capabilities?.includes('admin') || - user.capabilities?.includes(capability) - if (!hasAccess) { - throw new Error(`Missing required capability: ${capability}`) - } - return user + const guards = getAuthGuards() + return guards.requireCapability(request, capability) }) -// Utility functions for use in loaders/beforeLoad -// These use server functions internally and work in both SSR and client contexts - /** * Load user from session (non-blocking, returns null if not authenticated) * Can be called from loaders or beforeLoad @@ -60,7 +60,7 @@ export async function loadUser() { } /** - * Require authentication (throws redirect if not authenticated) + * Require authentication (throws if not authenticated) * Can be called from loaders or beforeLoad */ export async function requireAuthUser() { @@ -72,7 +72,7 @@ export async function requireAuthUser() { } /** - * Require a specific capability (throws redirect if not authenticated or missing capability) + * Require a specific capability (throws if not authorized) * Can be called from loaders or beforeLoad */ export async function requireCapabilityUser(capability: string) { diff --git a/src/utils/capabilities.server.ts b/src/utils/capabilities.server.ts index ec420e58..5ff6c212 100644 --- a/src/utils/capabilities.server.ts +++ b/src/utils/capabilities.server.ts @@ -1,103 +1,34 @@ -import { db } from '~/db/client' -import { users, roles, roleAssignments } from '~/db/schema' -import { eq, inArray } from 'drizzle-orm' -import type { Capability } from '~/db/schema' - -// Helper function to get effective capabilities (direct + role-based) -// Optimized to use single LEFT JOIN query to fetch both user and role capabilities +/** + * Capabilities Server Utilities + * + * This module delegates to the isolated auth module at ~/auth/ + * for backward compatibility with existing imports. + * + * For new code, import directly from '~/auth/index.server'. + */ + +import { getCapabilitiesRepository } from '~/auth/index.server' +import type { Capability } from '~/auth/index.server' + +// Re-export types for backward compatibility +export type { Capability } + +/** + * Get effective capabilities for a user (direct + role-based) + */ export async function getEffectiveCapabilities( userId: string, ): Promise { - // Single query to get both user capabilities and role capabilities - const result = await db - .select({ - userCapabilities: users.capabilities, - roleCapabilities: roles.capabilities, - }) - .from(users) - .leftJoin(roleAssignments, eq(roleAssignments.userId, users.id)) - .leftJoin(roles, eq(roles.id, roleAssignments.roleId)) - .where(eq(users.id, userId)) - - if (result.length === 0) { - return [] - } - - // Extract user capabilities (same for all rows) - const directCapabilities = result[0]?.userCapabilities || [] - - // Collect all role capabilities from all rows - const roleCapabilities = result - .map((r) => r.roleCapabilities) - .filter( - (caps): caps is Capability[] => caps !== null && Array.isArray(caps), - ) - .flat() - - // Union of direct capabilities and role capabilities - const effectiveCapabilities = Array.from( - new Set([...directCapabilities, ...roleCapabilities]), - ) - - return effectiveCapabilities + const repository = getCapabilitiesRepository() + return repository.getEffectiveCapabilities(userId) } -// Bulk function to get effective capabilities for multiple users efficiently -// Uses a single query with LEFT JOINs to fetch all user and role capabilities at once +/** + * Get effective capabilities for multiple users efficiently + */ export async function getBulkEffectiveCapabilities( userIds: string[], ): Promise> { - if (userIds.length === 0) { - return {} - } - - // Single query to get all user capabilities and role capabilities for all users - const result = await db - .select({ - userId: users.id, - userCapabilities: users.capabilities, - roleCapabilities: roles.capabilities, - }) - .from(users) - .leftJoin(roleAssignments, eq(roleAssignments.userId, users.id)) - .leftJoin(roles, eq(roles.id, roleAssignments.roleId)) - .where(inArray(users.id, userIds)) - - // Group results by userId - const userCapabilitiesMap: Record = {} - const userRoleCapabilitiesMap: Record = {} - - for (const row of result) { - const userId = row.userId - - // Store direct capabilities (same for all rows of the same user) - if (!userCapabilitiesMap[userId]) { - userCapabilitiesMap[userId] = row.userCapabilities || [] - } - - // Collect role capabilities - if (row.roleCapabilities && Array.isArray(row.roleCapabilities)) { - if (!userRoleCapabilitiesMap[userId]) { - userRoleCapabilitiesMap[userId] = [] - } - userRoleCapabilitiesMap[userId].push(...row.roleCapabilities) - } - } - - // Compute effective capabilities for each user - const effectiveCapabilitiesMap: Record = {} - - for (const userId of userIds) { - const directCapabilities = userCapabilitiesMap[userId] || [] - const roleCapabilities = userRoleCapabilitiesMap[userId] || [] - - // Union of direct capabilities and role capabilities - const effectiveCapabilities = Array.from( - new Set([...directCapabilities, ...roleCapabilities]), - ) - - effectiveCapabilitiesMap[userId] = effectiveCapabilities - } - - return effectiveCapabilitiesMap + const repository = getCapabilitiesRepository() + return repository.getBulkEffectiveCapabilities(userIds) } diff --git a/src/utils/cookies.server.ts b/src/utils/cookies.server.ts index 2ca6f1d2..d40da9bf 100644 --- a/src/utils/cookies.server.ts +++ b/src/utils/cookies.server.ts @@ -1,220 +1,40 @@ -// Cookie signing and verification utilities for stateless session management -// Uses HMAC-SHA256 to sign cookie data - -// Get secret key from environment (fallback for dev) -function getSecretKey(): string { - const secret = - process.env.SESSION_SECRET || 'dev-secret-key-change-in-production' - if ( - process.env.NODE_ENV === 'production' && - secret === 'dev-secret-key-change-in-production' - ) { - throw new Error('SESSION_SECRET must be set in production') - } - return secret -} - -// Base64URL encoding/decoding utilities (works in all environments) -function base64UrlEncode(str: string): string { - // Use btoa if available (browser/Node 18+), otherwise use TextEncoder + manual encoding - if (typeof btoa !== 'undefined') { - return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') - } - - // Fallback: manual base64 encoding - const encoder = new TextEncoder() - const bytes = encoder.encode(str) - const base64 = bytesToBase64(bytes) - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') -} - -function base64UrlDecode(str: string): string { - // Use atob if available (browser/Node 18+), otherwise use manual decoding - const normalized = str.replace(/-/g, '+').replace(/_/g, '/') - - if (typeof atob !== 'undefined') { - return atob(normalized) - } - - // Fallback: manual base64 decoding - const bytes = base64ToBytes(normalized) - const decoder = new TextDecoder() - return decoder.decode(bytes) -} - -function bytesToBase64(bytes: Uint8Array): string { - const chars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' - let result = '' - let i = 0 - - while (i < bytes.length) { - const a = bytes[i++] - const b = i < bytes.length ? bytes[i++] : 0 - const c = i < bytes.length ? bytes[i++] : 0 - - const bitmap = (a << 16) | (b << 8) | c - - result += chars.charAt((bitmap >> 18) & 63) - result += chars.charAt((bitmap >> 12) & 63) - result += i - 2 < bytes.length ? chars.charAt((bitmap >> 6) & 63) : '=' - result += i - 1 < bytes.length ? chars.charAt(bitmap & 63) : '=' - } - - return result -} - -function base64ToBytes(base64: string): Uint8Array { - const chars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' - const lookup = new Map() - for (let i = 0; i < chars.length; i++) { - lookup.set(chars[i], i) - } - - base64 = base64.replace(/=+$/, '') - const bytes: number[] = [] - - for (let i = 0; i < base64.length; i += 4) { - const enc1 = lookup.get(base64[i]) ?? 0 - const enc2 = lookup.get(base64[i + 1]) ?? 0 - const enc3 = lookup.get(base64[i + 2]) ?? 0 - const enc4 = lookup.get(base64[i + 3]) ?? 0 - - const bitmap = (enc1 << 18) | (enc2 << 12) | (enc3 << 6) | enc4 - - bytes.push((bitmap >> 16) & 255) - if (enc3 !== 64) bytes.push((bitmap >> 8) & 255) - if (enc4 !== 64) bytes.push(bitmap & 255) - } - - return new Uint8Array(bytes) -} - -// Cookie payload structure -export interface SessionCookieData { - userId: string - expiresAt: number // Unix timestamp in milliseconds - version: number // sessionVersion from users table -} - -// Sign cookie data using HMAC-SHA256 +/** + * Cookie Server Utilities + * + * This module delegates to the isolated auth module at ~/auth/ + * for backward compatibility with existing imports. + * + * For new code, import directly from '~/auth/index.server'. + */ + +import { getSessionService } from '~/auth/index.server' +import type { SessionCookieData } from '~/auth/index.server' + +// Re-export type for backward compatibility +export type { SessionCookieData } + +/** + * Sign cookie data using HMAC-SHA256 + */ export async function signCookie(data: SessionCookieData): Promise { - const secret = getSecretKey() - - // Create payload: base64url(userId:expiresAt:version) - const payload = `${data.userId}:${data.expiresAt}:${data.version}` - const payloadBase64 = base64UrlEncode(payload) - - // Create HMAC signature - const encoder = new TextEncoder() - const keyData = encoder.encode(secret) - const messageData = encoder.encode(payloadBase64) - - const key = await crypto.subtle.importKey( - 'raw', - keyData, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ) - - const signature = await crypto.subtle.sign('HMAC', key, messageData) - const signatureArray = new Uint8Array(signature) - // Convert Uint8Array to string efficiently (avoid spread operator for large arrays) - let signatureStr = '' - for (let i = 0; i < signatureArray.length; i++) { - signatureStr += String.fromCharCode(signatureArray[i]) - } - const signatureBase64 = base64UrlEncode(signatureStr) - - // Return: payload.signature - return `${payloadBase64}.${signatureBase64}` + const sessionService = getSessionService() + return sessionService.signCookie(data) } -// Verify and parse signed cookie +/** + * Verify and parse signed cookie + */ export async function verifyCookie( signedCookie: string, ): Promise { - try { - const secret = getSecretKey() - const [payloadBase64, signatureBase64] = signedCookie.split('.') - - if (!payloadBase64 || !signatureBase64) { - return null - } - - // Verify signature - const encoder = new TextEncoder() - const keyData = encoder.encode(secret) - const messageData = encoder.encode(payloadBase64) - - const key = await crypto.subtle.importKey( - 'raw', - keyData, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'], - ) - - // Decode signature - const signatureStr = base64UrlDecode(signatureBase64) - const signature = Uint8Array.from(signatureStr, (c) => c.charCodeAt(0)) - - const isValid = await crypto.subtle.verify( - 'HMAC', - key, - signature, - messageData, - ) - - if (!isValid) { - return null - } - - // Decode payload - const payload = base64UrlDecode(payloadBase64) - const [userId, expiresAtStr, versionStr] = payload.split(':') - - if (!userId || !expiresAtStr || !versionStr) { - return null - } - - const expiresAt = parseInt(expiresAtStr, 10) - const version = parseInt(versionStr, 10) - - // Check expiration - if (Date.now() > expiresAt) { - return null - } - - return { - userId, - expiresAt, - version, - } - } catch (error) { - console.error( - '[verifyCookie] Error verifying cookie:', - error instanceof Error ? error.message : 'Unknown error', - ) - return null - } + const sessionService = getSessionService() + return sessionService.verifyCookie(signedCookie) } -// Read session cookie from request +/** + * Read session cookie from request + */ export function getSessionCookie(request: Request): string | null { - const cookies = request.headers.get('cookie') || '' - const sessionCookie = cookies - .split(';') - .find((c) => c.trim().startsWith('session_token=')) - - if (!sessionCookie) { - return null - } - - // Extract and decode the token value (handles URL-encoded values) - const tokenValue = sessionCookie.split('=').slice(1).join('=').trim() - const sessionToken = decodeURIComponent(tokenValue) - return sessionToken || null + const sessionService = getSessionService() + return sessionService.getSessionCookie(request) } diff --git a/src/utils/oauth.server.ts b/src/utils/oauth.server.ts index 24f7ecb3..bd02bab8 100644 --- a/src/utils/oauth.server.ts +++ b/src/utils/oauth.server.ts @@ -1,164 +1,36 @@ -import { db } from '~/db/client' -import { oauthAccounts, users } from '~/db/schema' -import { eq, and } from 'drizzle-orm' -import type { OAuthProvider, NewOAuthAccount } from '~/db/schema' - -type OAuthProfile = { - id: string - email: string - name?: string - image?: string -} - -// Get OAuth account by provider and account ID +/** + * OAuth Server Utilities + * + * This module delegates to the isolated auth module at ~/auth/ + * for backward compatibility with existing imports. + * + * For new code, import directly from '~/auth/index.server'. + */ + +import { getOAuthService, getOAuthAccountRepository } from '~/auth/index.server' +import type { OAuthProvider, OAuthProfile, OAuthResult } from '~/auth/index.server' + +// Re-export types for backward compatibility +export type { OAuthProvider, OAuthProfile } + +/** + * Get OAuth account by provider and account ID + */ export async function getOAuthAccount( provider: OAuthProvider, providerAccountId: string, ) { - const account = await db.query.oauthAccounts.findFirst({ - where: and( - eq(oauthAccounts.provider, provider), - eq(oauthAccounts.providerAccountId, providerAccountId), - ), - }) - - return account + const repository = getOAuthAccountRepository() + return repository.findByProviderAndAccountId(provider, providerAccountId) } -// Upsert OAuth account and user +/** + * Upsert OAuth account and user + */ export async function upsertOAuthAccount( provider: OAuthProvider, profile: OAuthProfile, -) { - try { - // Check if OAuth account already exists - const existingAccount = await getOAuthAccount(provider, profile.id) - - if (existingAccount) { - // Account exists, update user info if needed - const user = await db.query.users.findFirst({ - where: eq(users.id, existingAccount.userId), - }) - - if (!user) { - console.error( - `[AUTH:ERROR] OAuth account exists for ${provider}:${profile.id} but user ${existingAccount.userId} not found`, - ) - throw new Error('User not found for existing OAuth account') - } - - if (user) { - const updates: { - email?: string - name?: string - image?: string - } = {} - - if (profile.email && user.email !== profile.email) { - updates.email = profile.email - } - if (profile.name && user.name !== profile.name) { - updates.name = profile.name - } - if (profile.image && user.image !== profile.image) { - updates.image = profile.image - } - - if (Object.keys(updates).length > 0) { - await db - .update(users) - .set({ ...updates, updatedAt: new Date() }) - .where(eq(users.id, existingAccount.userId)) - } - } - - return { - userId: existingAccount.userId, - isNewUser: false, - } - } - - // Find user by email (for linking multiple OAuth providers) - const existingUser = await db.query.users.findFirst({ - where: eq(users.email, profile.email), - }) - - let userId: string - - if (existingUser) { - // Link OAuth account to existing user - console.log( - `[AUTH:INFO] Linking ${provider} account to existing user ${existingUser.id} (${profile.email})`, - ) - userId = existingUser.id - - // Update user info if provided - const updates: { - name?: string - image?: string - } = {} - - if (profile.name && !existingUser.name) { - updates.name = profile.name - } - if (profile.image && !existingUser.image) { - updates.image = profile.image - } - - if (Object.keys(updates).length > 0) { - await db - .update(users) - .set({ ...updates, updatedAt: new Date() }) - .where(eq(users.id, userId)) - } - } else { - // Create new user - console.log( - `[AUTH:INFO] Creating new user for ${provider} login: ${profile.email}`, - ) - const [newUser] = await db - .insert(users) - .values({ - email: profile.email, - name: profile.name, - image: profile.image, - capabilities: [], - displayUsername: profile.name, - }) - .returning() - - if (!newUser) { - console.error( - `[AUTH:ERROR] Failed to create user for ${provider}:${profile.id} (${profile.email})`, - ) - throw new Error('Failed to create user') - } - - userId = newUser.id - } - - // Create OAuth account link - const newOAuthAccount: NewOAuthAccount = { - userId, - provider, - providerAccountId: profile.id, - email: profile.email, - } - - await db.insert(oauthAccounts).values(newOAuthAccount) - - return { - userId, - isNewUser: !existingUser, - } - } catch (error) { - console.error( - `[AUTH:ERROR] Failed to upsert OAuth account for ${provider}:${profile.id} (${profile.email}):`, - { - error: error instanceof Error ? error.message : 'Unknown error', - stack: error instanceof Error ? error.stack : undefined, - }, - ) - throw error - } +): Promise { + const oauthService = getOAuthService() + return oauthService.upsertOAuthAccount(provider, profile) } From 05fe6410f6deb979845a00265dd1da8bc6c57060 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 05:19:20 +0000 Subject: [PATCH 02/10] ci: apply automated fixes --- src/auth/auth.server.ts | 8 ++++++-- src/auth/context.server.ts | 4 +--- src/auth/oauth.server.ts | 5 +---- src/auth/repositories.server.ts | 6 ++++-- src/utils/oauth.server.ts | 6 +++++- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/auth/auth.server.ts b/src/auth/auth.server.ts index ca66f728..3f1ad330 100644 --- a/src/auth/auth.server.ts +++ b/src/auth/auth.server.ts @@ -97,7 +97,10 @@ export class AuthService implements IAuthService { /** * Map database user to AuthUser type */ - private mapDbUserToAuthUser(user: DbUser, capabilities: Capability[]): AuthUser { + private mapDbUserToAuthUser( + user: DbUser, + capabilities: Capability[], + ): AuthUser { return { userId: user.id, email: user.email, @@ -140,7 +143,8 @@ export async function requireCapability( const user = await requireAuthentication(authService, request) const hasAccess = - user.capabilities.includes('admin') || user.capabilities.includes(capability) + user.capabilities.includes('admin') || + user.capabilities.includes(capability) if (!hasAccess) { throw new AuthError( diff --git a/src/auth/context.server.ts b/src/auth/context.server.ts index 80b6979a..de2a152e 100644 --- a/src/auth/context.server.ts +++ b/src/auth/context.server.ts @@ -22,9 +22,7 @@ import { // ============================================================================ function getSessionSecret(): string { - return ( - process.env.SESSION_SECRET || 'dev-secret-key-change-in-production' - ) + return process.env.SESSION_SECRET || 'dev-secret-key-change-in-production' } function isProduction(): boolean { diff --git a/src/auth/oauth.server.ts b/src/auth/oauth.server.ts index 25441083..2f84cd18 100644 --- a/src/auth/oauth.server.ts +++ b/src/auth/oauth.server.ts @@ -243,10 +243,7 @@ export async function exchangeGitHubCode( console.error( '[OAuth] GitHub token exchange succeeded but no access_token returned', ) - throw new AuthError( - 'No access token received from GitHub', - 'OAUTH_ERROR', - ) + throw new AuthError('No access token received from GitHub', 'OAUTH_ERROR') } return tokenData.access_token diff --git a/src/auth/repositories.server.ts b/src/auth/repositories.server.ts index e79ab33f..ac6c2ebf 100644 --- a/src/auth/repositories.server.ts +++ b/src/auth/repositories.server.ts @@ -180,7 +180,8 @@ export class DrizzleCapabilitiesRepository implements ICapabilitiesRepository { } // Extract user capabilities (same for all rows) - const directCapabilities = (result[0]?.userCapabilities || []) as Capability[] + const directCapabilities = (result[0]?.userCapabilities || + []) as Capability[] // Collect all role capabilities from all rows const roleCapabilities = result @@ -226,7 +227,8 @@ export class DrizzleCapabilitiesRepository implements ICapabilitiesRepository { // Store direct capabilities (same for all rows of the same user) if (!userCapabilitiesMap[userId]) { - userCapabilitiesMap[userId] = (row.userCapabilities || []) as Capability[] + userCapabilitiesMap[userId] = (row.userCapabilities || + []) as Capability[] } // Collect role capabilities diff --git a/src/utils/oauth.server.ts b/src/utils/oauth.server.ts index bd02bab8..24ae00f5 100644 --- a/src/utils/oauth.server.ts +++ b/src/utils/oauth.server.ts @@ -8,7 +8,11 @@ */ import { getOAuthService, getOAuthAccountRepository } from '~/auth/index.server' -import type { OAuthProvider, OAuthProfile, OAuthResult } from '~/auth/index.server' +import type { + OAuthProvider, + OAuthProfile, + OAuthResult, +} from '~/auth/index.server' // Re-export types for backward compatibility export type { OAuthProvider, OAuthProfile } From 97c0f7d26ebf90764890ed69a6edaf2881a4565f Mon Sep 17 00:00:00 2001 From: Sarah Date: Thu, 18 Dec 2025 00:26:02 -0500 Subject: [PATCH 03/10] Site Updates (#583) * book icon update * fix book icon * update command icon * replace react-icons/bs * migrate to close icons to lucide-react x * discord, close, and github icons plus some others * update icons to lucide-react for improved consistency * replace FaBolt with Zap icon in multiple components for consistency * replace FontAwesome icons with Lucide icons for consistency * add CheckCircleIcon component and replace FaCheckCircle usage for consistency * remove unused FontAwesome icons for improved consistency * replace FaCogs with CogsIcon component for consistency across multiple files * replace FaComment with MessageSquare icon for consistency across multiple components * replace FontAwesome icons with Lucide icons for consistency across multiple components * replace FaExternalLinkAlt and FaEdit with ExternalLink and SquarePen icons for consistency across multiple components * replace FontAwesome icons with Lucide icons for consistency across multiple components * replace icons with Lucide icons for consistency across multiple components * replace icons with Lucide icons for consistency across multiple components * auth logs * update icons * add BrandXIcon and BSkyIcon components; update Navbar to use new icons * replace Material Design icons with Lucide icons for consistency across multiple components * replace react-icons with Lucide icons * replace react-icons with Lucide icons * ci: apply automated fixes * fix broken icon * enable lazy loading and async decoding for images in Markdown component --------- Co-authored-by: Tanner Linsley Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- package.json | 4 +- pnpm-lock.yaml | 24 +- src/components/CodeExplorerTopBar.tsx | 39 ++- src/components/CopyMarkdownButton.tsx | 6 +- src/components/Doc.tsx | 12 +- src/components/DocFeedbackFloatingButton.tsx | 8 +- src/components/DocFeedbackNote.tsx | 35 ++- src/components/DocFeedbackProvider.tsx | 8 +- src/components/DocsLayout.tsx | 22 +- src/components/FeatureGrid.tsx | 4 +- src/components/FeedEntry.tsx | 12 +- src/components/FeedEntryTimeline.tsx | 12 +- src/components/FeedFilters.tsx | 5 +- src/components/FeedbackLeaderboard.tsx | 10 +- src/components/FeedbackModerationList.tsx | 24 +- src/components/FilterComponents.tsx | 19 +- src/components/FrameworkCard.tsx | 6 +- src/components/MaintainerCard.tsx | 70 ----- src/components/Markdown.tsx | 14 +- src/components/Navbar.tsx | 102 ++++---- src/components/NotFound.tsx | 4 +- src/components/NotesModerationList.tsx | 10 +- src/components/OpenSourceStats.tsx | 13 +- src/components/PaginationControls.tsx | 6 +- src/components/PartnershipCallout.tsx | 5 +- src/components/SearchButton.tsx | 7 +- src/components/SearchModal.tsx | 6 +- src/components/Select.tsx | 6 +- src/components/Spinner.tsx | 4 +- src/components/ThemeToggle.tsx | 8 +- src/components/TocMobile.tsx | 6 +- src/components/UserFeedbackSection.tsx | 18 +- src/components/admin/FeedEntryEditor.tsx | 44 +++- src/components/admin/FeedSyncStatus.tsx | 6 +- src/components/icons/BSkyIcon.tsx | 27 ++ src/components/icons/BaseballCapIcon.tsx | 25 ++ src/components/icons/BrandXIcon.tsx | 28 ++ src/components/icons/CheckCircleIcon.tsx | 28 ++ src/components/icons/CogsIcon.tsx | 24 ++ src/components/icons/DiscordIcon.tsx | 24 ++ src/components/icons/GithubIcon.tsx | 24 ++ src/components/icons/GoogleIcon.tsx | 24 ++ src/components/icons/InstagramIcon.tsx | 24 ++ src/components/icons/NpmIcon.tsx | 26 ++ src/components/icons/YinYangIcon.tsx | 26 ++ src/libraries/ai.tsx | 16 +- src/libraries/config.tsx | 16 +- src/libraries/db.tsx | 22 +- src/libraries/devtools.tsx | 16 +- src/libraries/form.tsx | 18 +- src/libraries/pacer.tsx | 25 +- src/libraries/query.tsx | 18 +- src/libraries/ranger.tsx | 28 +- src/libraries/router.tsx | 28 +- src/libraries/start.tsx | 22 +- src/libraries/store.tsx | 17 +- src/libraries/table.tsx | 19 +- src/libraries/virtual.tsx | 19 +- .../$libraryId/$version.docs.contributors.tsx | 9 +- ...n.docs.framework.$framework.examples.$.tsx | 8 +- .../$version.docs.framework.index.tsx | 9 +- src/routes/_libraries/account/feedback.tsx | 8 +- src/routes/_libraries/account/index.tsx | 8 +- src/routes/_libraries/account/notes.tsx | 4 +- src/routes/_libraries/ads.tsx | 10 +- src/routes/_libraries/blog.$.tsx | 6 +- src/routes/_libraries/brand-guide.tsx | 8 +- .../_libraries/config.$version.index.tsx | 4 +- src/routes/_libraries/dashboard.tsx | 7 +- .../_libraries/devtools.$version.index.tsx | 4 +- src/routes/_libraries/feed.$id.tsx | 4 +- src/routes/_libraries/learn.tsx | 57 +---- src/routes/_libraries/login.tsx | 7 +- src/routes/_libraries/maintainers.tsx | 39 +-- src/routes/_libraries/paid-support.tsx | 11 +- src/routes/_libraries/partners.tsx | 6 +- .../_libraries/start.$version.index.tsx | 242 +----------------- src/routes/_libraries/workshops.tsx | 72 +++--- src/routes/admin/feed.index.tsx | 5 +- src/routes/admin/github-stats.tsx | 19 +- src/routes/admin/index.tsx | 23 +- src/routes/admin/npm-stats.tsx | 19 +- src/routes/admin/roles.$roleId.tsx | 17 +- src/routes/admin/roles.index.tsx | 48 ++-- src/routes/admin/route.tsx | 49 ++-- src/routes/admin/stats.tsx | 34 +-- src/routes/admin/users.tsx | 14 +- src/routes/merch.tsx | 22 +- src/routes/stats/npm/index.tsx | 135 ++-------- src/styles/app.css | 11 + 90 files changed, 948 insertions(+), 1094 deletions(-) create mode 100644 src/components/icons/BSkyIcon.tsx create mode 100644 src/components/icons/BaseballCapIcon.tsx create mode 100644 src/components/icons/BrandXIcon.tsx create mode 100644 src/components/icons/CheckCircleIcon.tsx create mode 100644 src/components/icons/CogsIcon.tsx create mode 100644 src/components/icons/DiscordIcon.tsx create mode 100644 src/components/icons/GithubIcon.tsx create mode 100644 src/components/icons/GoogleIcon.tsx create mode 100644 src/components/icons/InstagramIcon.tsx create mode 100644 src/components/icons/NpmIcon.tsx create mode 100644 src/components/icons/YinYangIcon.tsx diff --git a/package.json b/package.json index 21f618b5..d022bb9f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "sideEffects": false, "repository": "https://github.com/TanStack/tanstack.com.git", - "packageManager": "pnpm@9.4.0", + "packageManager": "pnpm@10.26.0", "type": "module", "scripts": { "dev": "pnpm run with-env vite dev", @@ -59,12 +59,12 @@ "hast-util-to-string": "^3.0.1", "html-react-parser": "^5.1.10", "lru-cache": "^7.13.1", + "lucide-react": "^0.561.0", "mermaid": "^11.11.0", "postgres": "^3.4.7", "react": "^19.2.0", "react-colorful": "^5.6.1", "react-dom": "^19.2.0", - "react-icons": "^5.3.0", "react-instantsearch": "7", "rehype-autolink-headings": "^7.1.0", "rehype-callouts": "^2.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4ac35f1..aa98532d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: lru-cache: specifier: ^7.13.1 version: 7.18.3 + lucide-react: + specifier: ^0.561.0 + version: 0.561.0(react@19.2.0) mermaid: specifier: ^11.11.0 version: 11.11.0 @@ -140,9 +143,6 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.0(react@19.2.0) - react-icons: - specifier: ^5.3.0 - version: 5.3.0(react@19.2.0) react-instantsearch: specifier: '7' version: 7.15.5(algoliasearch@5.23.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -5157,6 +5157,11 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lucide-react@0.561.0: + resolution: {integrity: sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.5.0: resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} engines: {node: '>=12'} @@ -5898,11 +5903,6 @@ packages: peerDependencies: react: ^19.2.0 - react-icons@5.3.0: - resolution: {integrity: sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==} - peerDependencies: - react: '*' - react-instantsearch-core@7.15.5: resolution: {integrity: sha512-SFxiwwMf0f5F/8U0Y4ullvQ7bZtbYE516UOJbxaHhjV8yY0i8c22K4lrBFrYbxVRT7QAcp2wLGHiB7r/lD7eRA==} peerDependencies: @@ -12439,6 +12439,10 @@ snapshots: lru-cache@7.18.3: {} + lucide-react@0.561.0(react@19.2.0): + dependencies: + react: 19.2.0 + luxon@3.5.0: {} magic-string@0.30.17: @@ -13341,10 +13345,6 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 - react-icons@5.3.0(react@19.2.0): - dependencies: - react: 19.2.0 - react-instantsearch-core@7.15.5(algoliasearch@5.23.4)(react@19.2.0): dependencies: '@babel/runtime': 7.24.5 diff --git a/src/components/CodeExplorerTopBar.tsx b/src/components/CodeExplorerTopBar.tsx index b998ca20..94aedc32 100644 --- a/src/components/CodeExplorerTopBar.tsx +++ b/src/components/CodeExplorerTopBar.tsx @@ -1,6 +1,11 @@ import React from 'react' -import { FaExpand, FaCompress } from 'react-icons/fa' -import { CgMenuLeft } from 'react-icons/cg' +import { + ArrowLeftFromLine, + ArrowRightFromLine, + Maximize, + Minimize, + TextAlignStart, +} from 'lucide-react' interface CodeExplorerTopBarProps { activeTab: 'code' | 'sandbox' @@ -23,16 +28,26 @@ export function CodeExplorerTopBar({
{activeTab === 'code' ? ( - + isSidebarOpen ? ( + + ) : ( + + ) ) : (
- +
)}
diff --git a/src/components/CopyMarkdownButton.tsx b/src/components/CopyMarkdownButton.tsx index 04062db2..21f68c89 100644 --- a/src/components/CopyMarkdownButton.tsx +++ b/src/components/CopyMarkdownButton.tsx @@ -1,9 +1,9 @@ 'use client' import { useState, useTransition } from 'react' -import { FaCheck, FaCopy } from 'react-icons/fa' import { type MouseEventHandler, useEffect, useRef } from 'react' import { useToast } from '~/components/ToastProvider' +import { Check, Copy } from 'lucide-react' export function useCopyButton( onCopy: () => void | Promise, @@ -115,11 +115,11 @@ export function CopyMarkdownButton({
{checked ? ( <> - Copied to Clipboard + Copied to Clipboard ) : ( <> - Copy Markdown + Copy Markdown )}
diff --git a/src/components/Doc.tsx b/src/components/Doc.tsx index 488fa833..88f7d611 100644 --- a/src/components/Doc.tsx +++ b/src/components/Doc.tsx @@ -1,9 +1,5 @@ import * as React from 'react' -import { - BsArrowsCollapseVertical, - BsArrowsExpandVertical, -} from 'react-icons/bs' -import { FaEdit } from 'react-icons/fa' +import { FoldHorizontal, SquarePen, UnfoldHorizontal } from 'lucide-react' import { twMerge } from 'tailwind-merge' import { useWidthToggle } from '~/components/DocsLayout' import { DocTitle } from '~/components/DocTitle' @@ -146,9 +142,9 @@ function DocContent({ title={isFullWidth ? 'Constrain width' : 'Expand width'} > {isFullWidth ? ( - + ) : ( - + )} )} @@ -186,7 +182,7 @@ function DocContent({ href={`https://github.com/${repo}/edit/${branch}/${filePath}`} className="flex items-center gap-2" > - Edit on GitHub + Edit on GitHub
diff --git a/src/components/DocFeedbackFloatingButton.tsx b/src/components/DocFeedbackFloatingButton.tsx index 60e5cb97..75d2b9a5 100644 --- a/src/components/DocFeedbackFloatingButton.tsx +++ b/src/components/DocFeedbackFloatingButton.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { FaPlus, FaComment, FaLightbulb } from 'react-icons/fa' import { twMerge } from 'tailwind-merge' +import { Lightbulb, MessageSquare, Plus } from 'lucide-react' interface DocFeedbackFloatingButtonProps { onAddNote: () => void @@ -111,7 +111,7 @@ export function DocFeedbackFloatingButton({ )} title="Add feedback" > - - +
Add Note @@ -148,7 +148,7 @@ export function DocFeedbackFloatingButton({ onClick={handleFeedbackClick} className="w-full px-4 py-3 flex items-center gap-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-left" > - +
Suggest Improvement diff --git a/src/components/DocFeedbackNote.tsx b/src/components/DocFeedbackNote.tsx index cc1e4b5d..b8220a8a 100644 --- a/src/components/DocFeedbackNote.tsx +++ b/src/components/DocFeedbackNote.tsx @@ -1,22 +1,21 @@ import * as React from 'react' import { twMerge } from 'tailwind-merge' -import { - FaComment, - FaLightbulb, - FaTrash, - FaChevronDown, - FaChevronUp, - FaSave, - FaTimes, -} from 'react-icons/fa' import { useMutation, useQueryClient } from '@tanstack/react-query' import { deleteDocFeedback, updateDocFeedback, updateDocFeedbackCollapsed, } from '~/utils/docFeedback.functions' -import { useToast } from '~/components/ToastProvider' import type { DocFeedback } from '~/db/schema' +import { + ChevronDown, + ChevronUp, + Lightbulb, + MessageSquare, + Save, + Trash, + X, +} from 'lucide-react' interface DocFeedbackNoteProps { note: DocFeedback @@ -42,13 +41,13 @@ export function DocFeedbackNote({ // Theme based on type const isImprovement = note.type === 'improvement' - const Icon = isImprovement ? FaLightbulb : FaComment + const Icon = isImprovement ? Lightbulb : MessageSquare const colors = isImprovement ? { bg: 'bg-yellow-50 dark:bg-yellow-900/20', border: 'border-yellow-400 dark:border-yellow-600', header: 'bg-yellow-100 dark:bg-yellow-900/30', - icon: 'text-yellow-600 dark:text-yellow-500', + icon: 'text-yellow-600 dark:text-yellow-500 text-[14px]', text: 'text-yellow-800 dark:text-yellow-300', timestamp: 'text-yellow-700 dark:text-yellow-400', deleteHover: 'hover:text-yellow-600 dark:hover:text-yellow-400', @@ -57,7 +56,7 @@ export function DocFeedbackNote({ bg: 'bg-blue-50 dark:bg-blue-900/20', border: 'border-blue-400 dark:border-blue-600', header: 'bg-blue-100 dark:bg-blue-900/30', - icon: 'text-blue-600 dark:text-blue-500', + icon: 'text-blue-600 dark:text-blue-500 text-[14px]', text: 'text-blue-800 dark:text-blue-300', timestamp: 'text-blue-700 dark:text-blue-400', deleteHover: 'hover:text-blue-600 dark:hover:text-blue-400', @@ -377,7 +376,7 @@ export function DocFeedbackNote({ title={isImprovement ? 'Delete improvement' : 'Delete note'} disabled={isDeleting || isSaving} > - + )} @@ -394,9 +393,9 @@ export function DocFeedbackNote({ } > {note.isCollapsed ? ( - + ) : ( - + )}
@@ -444,7 +443,7 @@ export function DocFeedbackNote({ )} disabled={isSaving} > - + {isSaving ? 'Saving...' : 'Save'}
diff --git a/src/components/DocFeedbackProvider.tsx b/src/components/DocFeedbackProvider.tsx index 3ca046c0..a0a6cde0 100644 --- a/src/components/DocFeedbackProvider.tsx +++ b/src/components/DocFeedbackProvider.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import * as ReactDOM from 'react-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { twMerge } from 'tailwind-merge' -import { FaComment, FaLightbulb } from 'react-icons/fa' import { DocFeedbackNote } from './DocFeedbackNote' import { DocFeedbackFloatingButton } from './DocFeedbackFloatingButton' import { getDocFeedbackForPageQueryOptions } from '~/queries/docFeedback' @@ -13,6 +12,7 @@ import { getBlockIdentifier, } from '~/utils/docFeedback.client' import type { DocFeedback } from '~/db/schema' +import { Lightbulb, MessageSquare } from 'lucide-react' interface DocFeedbackProviderProps { children: React.ReactNode @@ -536,21 +536,21 @@ function CreatingFeedbackNote({ // Theme based on type const isImprovement = type === 'improvement' - const Icon = isImprovement ? FaLightbulb : FaComment + const Icon = isImprovement ? Lightbulb : MessageSquare const colors = isImprovement ? { bg: 'bg-yellow-50 dark:bg-yellow-900/20', border: 'border-yellow-400 dark:border-yellow-600', header: 'bg-yellow-100 dark:bg-yellow-900/30', - icon: 'text-yellow-600 dark:text-yellow-500', + icon: 'text-yellow-600 dark:text-yellow-500 text-[14px]', text: 'text-yellow-800 dark:text-yellow-300', } : { bg: 'bg-blue-50 dark:bg-blue-900/20', border: 'border-blue-400 dark:border-blue-600', header: 'bg-blue-100 dark:bg-blue-900/30', - icon: 'text-blue-600 dark:text-blue-500', + icon: 'text-blue-600 dark:text-blue-500 text-[14px]', text: 'text-blue-800 dark:text-blue-300', } diff --git a/src/components/DocsLayout.tsx b/src/components/DocsLayout.tsx index 68d7e155..9a10df7c 100644 --- a/src/components/DocsLayout.tsx +++ b/src/components/DocsLayout.tsx @@ -1,6 +1,7 @@ import * as React from 'react' -import { CgClose, CgMenuLeft } from 'react-icons/cg' -import { FaArrowLeft, FaArrowRight, FaDiscord, FaGithub } from 'react-icons/fa' +import { X, TextAlignStart, ArrowLeft, ArrowRight } from 'lucide-react' +import { GithubIcon } from '~/components/icons/GithubIcon' +import { DiscordIcon } from '~/components/icons/DiscordIcon' import { Link, useMatches, useParams } from '@tanstack/react-router' import { useLocalStorage } from '~/utils/useLocalStorage' import { last } from '~/utils/utils' @@ -79,7 +80,7 @@ const useMenuConfig = ({ { label: (
- GitHub + GitHub
), to: `https://github.com/${repo}`, @@ -87,7 +88,7 @@ const useMenuConfig = ({ { label: (
- Discord + Discord
), to: 'https://tlinz.com/discord', @@ -140,14 +141,11 @@ type DocsLayoutProps = { } export function DocsLayout({ - name, - version, colorFrom, colorTo, textColor, config, frameworks, - versions, repo, children, }: DocsLayoutProps) { @@ -155,8 +153,6 @@ export function DocsLayout({ from: '/$libraryId/$version/docs', }) const { _splat } = useParams({ strict: false }) - // const frameworkConfig = useFrameworkConfig({ frameworks }) - // const versionConfig = useVersionConfig({ versions }) const menuConfig = useMenuConfig({ config, frameworks, repo }) const matches = useMatches() @@ -292,8 +288,8 @@ export function DocsLayout({ >
- - + + Documentation
@@ -363,7 +359,7 @@ export function DocsLayout({ className="py-1 px-2 bg-white/70 text-black dark:bg-gray-500/40 dark:text-white shadow-lg shadow-black/20 flex items-center justify-center backdrop-blur-sm z-20 rounded-lg overflow-hidden" >
- + {prevItem.label}
@@ -383,7 +379,7 @@ export function DocsLayout({ > {nextItem.label} {' '} - +
) : null} diff --git a/src/components/FeatureGrid.tsx b/src/components/FeatureGrid.tsx index ac332652..df1b4443 100644 --- a/src/components/FeatureGrid.tsx +++ b/src/components/FeatureGrid.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { FaCheckCircle } from 'react-icons/fa' +import { CheckCircleIcon } from '~/components/icons/CheckCircleIcon' type FeatureGridProps = { title?: string @@ -25,7 +25,7 @@ export function FeatureGrid({ title, items, gridClassName }: FeatureGridProps) { > {items.map((d, i) => ( - {d} + {d} ))}
diff --git a/src/components/FeedEntry.tsx b/src/components/FeedEntry.tsx index 80a5e1b1..b90a3684 100644 --- a/src/components/FeedEntry.tsx +++ b/src/components/FeedEntry.tsx @@ -3,9 +3,9 @@ import { Markdown } from '~/components/Markdown' import { libraries } from '~/libraries' import { partners } from '~/utils/partners' import { twMerge } from 'tailwind-merge' -import { FaEdit, FaTrash, FaEye, FaEyeSlash, FaStar } from 'react-icons/fa' import { Link } from '@tanstack/react-router' import { TableRow, TableCell } from '~/components/TableComponents' +import { Eye, EyeOff, SquarePen, Star, Trash } from 'lucide-react' export interface FeedEntry { _id: string @@ -303,7 +303,7 @@ export function FeedEntry({ className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors text-gray-600 dark:text-gray-400" title="Edit" > - + )} {adminActions.onToggleVisibility && ( @@ -315,9 +315,9 @@ export function FeedEntry({ title={entry.isVisible ? 'Hide' : 'Show'} > {entry.isVisible ? ( - + ) : ( - + )} )} @@ -333,7 +333,7 @@ export function FeedEntry({ }`} title="Toggle Featured" > - + )} {adminActions.onDelete && ( @@ -342,7 +342,7 @@ export function FeedEntry({ className="p-0.5 hover:bg-red-100 dark:hover:bg-red-900 rounded transition-colors text-red-500" title="Delete" > - + )}
diff --git a/src/components/FeedEntryTimeline.tsx b/src/components/FeedEntryTimeline.tsx index 5fd745bd..70d97780 100644 --- a/src/components/FeedEntryTimeline.tsx +++ b/src/components/FeedEntryTimeline.tsx @@ -4,9 +4,9 @@ import { Markdown } from '~/components/Markdown' import { libraries } from '~/libraries' import { partners } from '~/utils/partners' import { twMerge } from 'tailwind-merge' -import { FaEdit, FaTrash, FaEye, FaEyeSlash, FaStar } from 'react-icons/fa' import { FeedEntry } from './FeedEntry' import { Link } from '@tanstack/react-router' +import { Eye, EyeOff, SquarePen, Star, Trash } from 'lucide-react' interface FeedEntryTimelineProps { entry: FeedEntry @@ -250,7 +250,7 @@ export function FeedEntryTimeline({ className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors text-gray-600 dark:text-gray-400" title="Edit" > - + )} {adminActions.onToggleVisibility && ( @@ -262,9 +262,9 @@ export function FeedEntryTimeline({ title={entry.isVisible ? 'Hide' : 'Show'} > {entry.isVisible ? ( - + ) : ( - + )} )} @@ -281,7 +281,7 @@ export function FeedEntryTimeline({ )} title="Toggle Featured" > - + )} {adminActions.onDelete && ( @@ -290,7 +290,7 @@ export function FeedEntryTimeline({ className="p-2 hover:bg-red-100 dark:hover:bg-red-900 rounded transition-colors text-red-500" title="Delete" > - + )} diff --git a/src/components/FeedFilters.tsx b/src/components/FeedFilters.tsx index edabfcdc..deae897b 100644 --- a/src/components/FeedFilters.tsx +++ b/src/components/FeedFilters.tsx @@ -1,10 +1,9 @@ import * as React from 'react' import { useState } from 'react' -import { LuHelpCircle, LuRotateCcw } from 'react-icons/lu' +import { RotateCcw } from 'lucide-react' import { useDebouncedValue } from '@tanstack/react-pacer' import { Library, type LibraryId } from '~/libraries' import { partners } from '~/utils/partners' -import { Tooltip } from '~/components/Tooltip' import { FEED_CATEGORIES, RELEASE_LEVELS, @@ -477,7 +476,7 @@ export function FeedFilters({ onClick={onClearFilters} className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded transition-colors" > - + Reset Filters diff --git a/src/components/FeedbackLeaderboard.tsx b/src/components/FeedbackLeaderboard.tsx index 1e747e63..0f9a7b05 100644 --- a/src/components/FeedbackLeaderboard.tsx +++ b/src/components/FeedbackLeaderboard.tsx @@ -14,7 +14,7 @@ import { } from './TableComponents' import { PaginationControls } from './PaginationControls' import { twMerge } from 'tailwind-merge' -import { FaTrophy, FaMedal, FaAward } from 'react-icons/fa' +import { Award, Medal, Trophy } from 'lucide-react' export function FeedbackLeaderboard() { const navigate = useNavigate({ from: '/_libraries/feedback-leaderboard' }) @@ -67,9 +67,9 @@ export function FeedbackLeaderboard() { } const getRankIcon = (rank: number) => { - if (rank === 1) return - if (rank === 2) return - if (rank === 3) return + if (rank === 1) return + if (rank === 2) return + if (rank === 3) return return null } @@ -79,7 +79,7 @@ export function FeedbackLeaderboard() {
- +

Documentation Feedback Leaderboard

diff --git a/src/components/FeedbackModerationList.tsx b/src/components/FeedbackModerationList.tsx index aa7e3251..45f4d973 100644 --- a/src/components/FeedbackModerationList.tsx +++ b/src/components/FeedbackModerationList.tsx @@ -1,12 +1,5 @@ import * as React from 'react' import { twMerge } from 'tailwind-merge' -import { - FaCheck, - FaTimes, - FaComment, - FaLightbulb, - FaExclamationTriangle, -} from 'react-icons/fa' import { Table, TableHeader, @@ -20,6 +13,8 @@ import { PaginationControls } from './PaginationControls' import { Spinner } from './Spinner' import type { DocFeedback } from '~/db/schema' import { calculatePoints } from '~/utils/docFeedback.client' +import { Check, Lightbulb, TriangleAlert } from 'lucide-react' +import { MessageSquare, X } from 'lucide-react' interface FeedbackModerationListProps { data: @@ -174,9 +169,9 @@ export function FeedbackModerationList({
{feedback.type === 'note' ? ( - + ) : ( - + )} {feedback.type === 'note' ? 'Note' : 'Improvement'} @@ -213,7 +208,7 @@ export function FeedbackModerationList({ {feedback.isDetached && (
- + Detached
)} @@ -235,14 +230,14 @@ export function FeedbackModerationList({ className="px-3 py-1 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded transition-colors" title="Approve" > - +
)} @@ -309,7 +304,10 @@ export function FeedbackModerationList({ {/* Moderation Note Input (for pending only) */} {isPending && (
-