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/auth/auth.server.ts b/src/auth/auth.server.ts new file mode 100644 index 00000000..3f1ad330 --- /dev/null +++ b/src/auth/auth.server.ts @@ -0,0 +1,167 @@ +/** + * 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..de2a152e --- /dev/null +++ b/src/auth/context.server.ts @@ -0,0 +1,144 @@ +/** + * 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..044be1b7 --- /dev/null +++ b/src/auth/oauth.server.ts @@ -0,0 +1,388 @@ +/** + * 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() + + if (!Array.isArray(emails)) { + console.error( + `[OAuth] GitHub emails API returned non-array response:`, + emails, + ) + throw new AuthError( + emails?.message || 'Failed to fetch GitHub emails', + 'OAUTH_ERROR', + ) + } + + 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..ac6c2ebf --- /dev/null +++ b/src/auth/repositories.server.ts @@ -0,0 +1,277 @@ +/** + * 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/components/AnnouncementBanner.tsx b/src/components/AnnouncementBanner.tsx new file mode 100644 index 00000000..03162478 --- /dev/null +++ b/src/components/AnnouncementBanner.tsx @@ -0,0 +1,233 @@ +import * as React from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useLocation, Link } from '@tanstack/react-router' +import { + X, + Info, + AlertTriangle, + CheckCircle, + Gift, + ExternalLink, + ArrowRight, +} from 'lucide-react' +import { + getActiveBanners, + getDismissedBannerIds, + dismissBanner, + type ActiveBanner, +} from '~/utils/banner.functions' + +const DISMISSED_BANNERS_KEY = 'tanstack_dismissed_banners' + +// Get dismissed banner IDs from localStorage (for anonymous users) +function getLocalDismissedBanners(): string[] { + if (typeof window === 'undefined') return [] + try { + const stored = localStorage.getItem(DISMISSED_BANNERS_KEY) + return stored ? JSON.parse(stored) : [] + } catch { + return [] + } +} + +// Save dismissed banner ID to localStorage +function saveLocalDismissedBanner(bannerId: string) { + if (typeof window === 'undefined') return + try { + const existing = getLocalDismissedBanners() + if (!existing.includes(bannerId)) { + localStorage.setItem( + DISMISSED_BANNERS_KEY, + JSON.stringify([...existing, bannerId]), + ) + } + } catch { + // Ignore localStorage errors + } +} + +const BANNER_STYLES = { + info: { + icon: Info, + bgClass: + 'bg-blue-100/90 dark:bg-blue-950/90 backdrop-blur-md border-blue-200 dark:border-blue-800', + textClass: 'text-blue-900 dark:text-blue-100', + iconClass: 'text-blue-600 dark:text-blue-400', + linkClass: + 'text-blue-700 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-200', + }, + warning: { + icon: AlertTriangle, + bgClass: + 'bg-amber-100/90 dark:bg-amber-950/90 backdrop-blur-md border-amber-200 dark:border-amber-800', + textClass: 'text-amber-900 dark:text-amber-100', + iconClass: 'text-amber-600 dark:text-amber-400', + linkClass: + 'text-amber-700 dark:text-amber-300 hover:text-amber-800 dark:hover:text-amber-200', + }, + success: { + icon: CheckCircle, + bgClass: + 'bg-green-100/90 dark:bg-green-950/90 backdrop-blur-md border-green-200 dark:border-green-800', + textClass: 'text-green-900 dark:text-green-100', + iconClass: 'text-green-600 dark:text-green-400', + linkClass: + 'text-green-700 dark:text-green-300 hover:text-green-800 dark:hover:text-green-200', + }, + promo: { + icon: Gift, + bgClass: + 'bg-purple-100/90 dark:bg-purple-950/90 backdrop-blur-md border-purple-200 dark:border-purple-800', + textClass: 'text-purple-900 dark:text-purple-100', + iconClass: 'text-purple-600 dark:text-purple-400', + linkClass: + 'text-purple-700 dark:text-purple-300 hover:text-purple-800 dark:hover:text-purple-200', + }, +} as const + +interface BannerItemProps { + banner: ActiveBanner + onDismiss: (bannerId: string) => void +} + +function BannerItem({ banner, onDismiss }: BannerItemProps) { + const style = BANNER_STYLES[banner.style] || BANNER_STYLES.info + const Icon = style.icon + const isExternalLink = + banner.linkUrl?.startsWith('http://') || + banner.linkUrl?.startsWith('https://') + const isInternalLink = banner.linkUrl?.startsWith('/') + + const linkContent = banner.linkUrl && ( + + {banner.linkText || 'Learn More'} + {isExternalLink ? ( + + ) : ( + + )} + + ) + + return ( +
+
+
+ +
+
{banner.title}
+ {banner.content && ( +
+ {banner.content} +
+ )} + {banner.linkUrl && ( +
+ {isExternalLink ? ( + + {linkContent} + + ) : isInternalLink ? ( + {linkContent} + ) : ( + {linkContent} + )} +
+ )} +
+ +
+
+
+ ) +} + +export function AnnouncementBanner() { + const location = useLocation() + const queryClient = useQueryClient() + const [localDismissed, setLocalDismissed] = React.useState([]) + + // Load local dismissed banners on mount (client-side only) + React.useEffect(() => { + setLocalDismissed(getLocalDismissedBanners()) + }, []) + + // Fetch active banners for current path + const bannersQuery = useQuery({ + queryKey: ['activeBanners', location.pathname], + queryFn: () => getActiveBanners({ data: { pathname: location.pathname } }), + staleTime: 1000 * 60 * 5, // 5 minutes + }) + + // Fetch dismissed banner IDs for logged-in users + const dismissedQuery = useQuery({ + queryKey: ['dismissedBanners'], + queryFn: () => getDismissedBannerIds(), + staleTime: 1000 * 60 * 5, // 5 minutes + }) + + // Mutation to dismiss a banner + const dismissMutation = useMutation({ + mutationFn: (bannerId: string) => dismissBanner({ data: { bannerId } }), + onSuccess: (result, bannerId) => { + if (result.success) { + // Server dismissal succeeded (logged-in user) + queryClient.invalidateQueries({ queryKey: ['dismissedBanners'] }) + } + // Always save to localStorage as fallback + saveLocalDismissedBanner(bannerId) + setLocalDismissed((prev) => [...prev, bannerId]) + }, + onError: (_, bannerId) => { + // If server fails, still save locally + saveLocalDismissedBanner(bannerId) + setLocalDismissed((prev) => [...prev, bannerId]) + }, + }) + + const handleDismiss = (bannerId: string) => { + dismissMutation.mutate(bannerId) + } + + // Combine server and local dismissed IDs + const allDismissed = React.useMemo(() => { + const serverDismissed = dismissedQuery.data || [] + return new Set([...serverDismissed, ...localDismissed]) + }, [dismissedQuery.data, localDismissed]) + + // Filter out dismissed banners + const visibleBanners = React.useMemo(() => { + if (!bannersQuery.data) return [] + return bannersQuery.data.filter((banner) => !allDismissed.has(banner.id)) + }, [bannersQuery.data, allDismissed]) + + if (visibleBanners.length === 0) { + return null + } + + return ( +
+ {visibleBanners.map((banner) => ( + + ))} +
+ ) +} 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..e763e773 100644 --- a/src/components/FeedEntry.tsx +++ b/src/components/FeedEntry.tsx @@ -3,26 +3,28 @@ 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 id: string - source: string + entryType: 'release' | 'blog' | 'announcement' title: string content: string - excerpt?: string + excerpt?: string | null publishedAt: number + createdAt: number + updatedAt?: number metadata?: any libraryIds: string[] partnerIds?: string[] tags: string[] - category: 'release' | 'announcement' | 'blog' | 'partner' | 'update' | 'other' - isVisible: boolean + showInFeed: boolean featured?: boolean autoSynced: boolean + lastSyncedAt?: number } interface FeedEntryProps { @@ -93,14 +95,21 @@ export function FeedEntry({ className: 'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200', }, + update: { + label: 'Update', + className: + 'bg-teal-100 dark:bg-teal-900 text-teal-800 dark:text-teal-200', + }, } - const category = entry.category - const key = category === 'release' && isPrerelease ? 'prerelease' : category + const key = + entry.entryType === 'release' && isPrerelease + ? 'prerelease' + : entry.entryType return ( badgeConfigs[key] || { - label: entry.source, + label: entry.entryType, className: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200', } @@ -151,10 +160,10 @@ export function FeedEntry({ // Determine external link if available const getExternalLink = () => { if (entry.metadata) { - if (entry.source === 'github' && entry.metadata.url) { + if (entry.entryType === 'release' && entry.metadata.url) { return entry.metadata.url } - if (entry.source === 'blog' && entry.metadata.url) { + if (entry.entryType === 'blog' && entry.metadata.url) { return entry.metadata.url } } @@ -276,7 +285,7 @@ export function FeedEntry({ ⭐ )} - {!entry.isVisible && ( + {!entry.showInFeed && ( Hidden @@ -303,21 +312,21 @@ 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 && ( )} @@ -333,7 +342,7 @@ export function FeedEntry({ }`} title="Toggle Featured" > - + )} {adminActions.onDelete && ( @@ -342,7 +351,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" > - + )}
@@ -357,9 +366,6 @@ export function FeedEntry({
{/* Metadata Row */}
- {entry.source !== 'announcement' && ( - {entry.source} - )} {entryLibraries.length > 0 && (
Libraries: @@ -422,7 +428,8 @@ export function FeedEntry({ className="text-blue-600 dark:text-blue-400 hover:underline text-xs font-medium" onClick={(e) => e.stopPropagation()} > - View on {entry.source === 'github' ? 'GitHub' : 'Blog'} → + View on {entry.entryType === 'release' ? 'GitHub' : 'Blog'}{' '} + →
)} diff --git a/src/components/FeedEntryTimeline.tsx b/src/components/FeedEntryTimeline.tsx index 5fd745bd..2d43f83b 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 @@ -73,14 +73,21 @@ export function FeedEntryTimeline({ className: 'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200', }, + update: { + label: 'Update', + className: + 'bg-teal-100 dark:bg-teal-900 text-teal-800 dark:text-teal-200', + }, } - const category = entry.category - const key = category === 'release' && isPrerelease ? 'prerelease' : category + const key = + entry.entryType === 'release' && isPrerelease + ? 'prerelease' + : entry.entryType return ( badgeConfigs[key] || { - label: entry.source, + label: entry.entryType, className: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200', } @@ -128,10 +135,10 @@ export function FeedEntryTimeline({ // Determine external link if available const getExternalLink = () => { if (entry.metadata) { - if (entry.source === 'github' && entry.metadata.url) { + if (entry.entryType === 'release' && entry.metadata.url) { return entry.metadata.url } - if (entry.source === 'blog' && entry.metadata.url) { + if (entry.entryType === 'blog' && entry.metadata.url) { return entry.metadata.url } } @@ -177,7 +184,7 @@ export function FeedEntryTimeline({ ⭐ Featured )} - {!entry.isVisible && ( + {!entry.showInFeed && ( Hidden @@ -205,8 +212,10 @@ export function FeedEntryTimeline({ addSuffix: true, })} - {entry.source !== 'announcement' && ( - {entry.source} + {entry.autoSynced && ( + + {entry.entryType === 'release' ? 'GitHub' : entry.entryType} + )} {entryLibraries.length > 0 && (
@@ -250,21 +259,21 @@ 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 && ( )} @@ -281,7 +290,7 @@ export function FeedEntryTimeline({ )} title="Toggle Featured" > - + )} {adminActions.onDelete && ( @@ -290,7 +299,7 @@ export function FeedEntryTimeline({ className="p-2 hover:bg-red-100 dark:hover:bg-red-900 rounded transition-colors text-red-500" title="Delete" > - + )}
@@ -345,7 +354,7 @@ export function FeedEntryTimeline({ className="text-blue-600 dark:text-blue-400 hover:underline text-sm font-medium" onClick={(e) => e.stopPropagation()} > - View on {entry.source === 'github' ? 'GitHub' : 'Blog'} → + View on {entry.entryType === 'release' ? 'GitHub' : 'Blog'} →
)} diff --git a/src/components/FeedFilters.tsx b/src/components/FeedFilters.tsx index edabfcdc..d677fb25 100644 --- a/src/components/FeedFilters.tsx +++ b/src/components/FeedFilters.tsx @@ -1,14 +1,13 @@ 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, + ENTRY_TYPES, RELEASE_LEVELS, - type FeedCategory, + type EntryType, type ReleaseLevel, } from '~/utils/feedSchema' import { @@ -20,8 +19,7 @@ import { } from '~/components/FilterComponents' export interface FeedFacetCounts { - sources?: Record - categories?: Record + entryTypes?: Record libraries?: Record partners?: Record releaseLevels?: Record @@ -32,9 +30,8 @@ export interface FeedFacetCounts { interface FeedFiltersProps { libraries: Library[] partners: typeof partners - selectedSources?: string[] + selectedEntryTypes?: EntryType[] selectedLibraries?: string[] // Library IDs, not Library objects - selectedCategories?: string[] selectedPartners?: string[] selectedTags?: string[] selectedReleaseLevels?: ReleaseLevel[] @@ -45,9 +42,8 @@ interface FeedFiltersProps { viewMode?: 'table' | 'timeline' onViewModeChange?: (viewMode: 'table' | 'timeline') => void onFiltersChange: (filters: { - sources?: string[] + entryTypes?: EntryType[] libraries?: LibraryId[] - categories?: FeedCategory[] partners?: string[] tags?: string[] releaseLevels?: ReleaseLevel[] @@ -58,14 +54,11 @@ interface FeedFiltersProps { onClearFilters: () => void } -const SOURCES = ['github', 'blog', 'announcement'] as const - export function FeedFilters({ libraries, partners, - selectedSources, + selectedEntryTypes, selectedLibraries, - selectedCategories, selectedPartners, selectedTags, selectedReleaseLevels, @@ -81,9 +74,8 @@ export function FeedFilters({ const [expandedSections, setExpandedSections] = useState< Record >({ - sources: true, + entryTypes: true, libraries: true, - categories: true, releaseLevels: true, prerelease: true, partners: true, @@ -97,12 +89,12 @@ export function FeedFilters({ })) } - const toggleSource = (source: string) => { - const current = selectedSources || [] - const updated = current.includes(source) - ? current.filter((s) => s !== source) - : [...current, source] - onFiltersChange({ sources: updated.length > 0 ? updated : undefined }) + const toggleEntryType = (entryType: EntryType) => { + const current = selectedEntryTypes || [] + const updated = current.includes(entryType) + ? current.filter((t) => t !== entryType) + : [...current, entryType] + onFiltersChange({ entryTypes: updated.length > 0 ? updated : undefined }) } const toggleLibrary = (libraryId: string) => { @@ -115,16 +107,6 @@ export function FeedFilters({ }) } - const toggleCategory = (category: FeedCategory) => { - const current = selectedCategories || [] - const updated = current.includes(category) - ? current.filter((c) => c !== category) - : [...current, category] - onFiltersChange({ - categories: updated.length > 0 ? (updated as FeedCategory[]) : undefined, - }) - } - const togglePartner = (partnerId: string) => { const current = selectedPartners || [] const updated = current.includes(partnerId) @@ -187,9 +169,8 @@ export function FeedFilters({ } const hasActiveFilters = Boolean( - (selectedSources && selectedSources.length > 0) || + (selectedEntryTypes && selectedEntryTypes.length > 0) || (selectedLibraries && selectedLibraries.length > 0) || - (selectedCategories && selectedCategories.length > 0) || (selectedPartners && selectedPartners.length > 0) || (selectedTags && selectedTags.length > 0) || featured !== undefined || @@ -253,73 +234,36 @@ export function FeedFilters({ /> - {/* Sources */} - { - onFiltersChange({ sources: [...SOURCES] }) - }} - onSelectNone={() => { - onFiltersChange({ sources: undefined }) - }} - isAllSelected={ - selectedSources !== undefined && - selectedSources.length === SOURCES.length - } - isSomeSelected={ - selectedSources !== undefined && - selectedSources.length > 0 && - selectedSources.length < SOURCES.length - } - expandedSections={expandedSections} - onToggleSection={toggleSection} - > - {SOURCES.map((source) => { - const count = facetCounts?.sources?.[source] - return ( - toggleSource(source)} - count={count} - capitalize - /> - ) - })} - - - {/* Categories */} + {/* Entry Types */} { - onFiltersChange({ categories: [...FEED_CATEGORIES] }) + onFiltersChange({ entryTypes: [...ENTRY_TYPES] }) }} onSelectNone={() => { - onFiltersChange({ categories: undefined }) + onFiltersChange({ entryTypes: undefined }) }} isAllSelected={ - selectedCategories !== undefined && - selectedCategories.length === FEED_CATEGORIES.length + selectedEntryTypes !== undefined && + selectedEntryTypes.length === ENTRY_TYPES.length } isSomeSelected={ - selectedCategories !== undefined && - selectedCategories.length > 0 && - selectedCategories.length < FEED_CATEGORIES.length + selectedEntryTypes !== undefined && + selectedEntryTypes.length > 0 && + selectedEntryTypes.length < ENTRY_TYPES.length } expandedSections={expandedSections} onToggleSection={toggleSection} > - {FEED_CATEGORIES.map((category) => { - const count = facetCounts?.categories?.[category] + {ENTRY_TYPES.map((entryType) => { + const count = facetCounts?.entryTypes?.[entryType] return ( toggleCategory(category)} + key={entryType} + label={entryType} + checked={selectedEntryTypes?.includes(entryType) ?? false} + onChange={() => toggleEntryType(entryType)} count={count} capitalize /> @@ -477,7 +421,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/FeedList.tsx b/src/components/FeedList.tsx index 8abf83bb..86329dbc 100644 --- a/src/components/FeedList.tsx +++ b/src/components/FeedList.tsx @@ -1,5 +1,9 @@ import * as React from 'react' -import { UseQueryResult, UseInfiniteQueryResult } from '@tanstack/react-query' +import { + UseQueryResult, + UseInfiniteQueryResult, + InfiniteData, +} from '@tanstack/react-query' import { FeedEntry } from '~/components/FeedEntry' import { FeedEntryTimeline } from '~/components/FeedEntryTimeline' import { Spinner } from '~/components/Spinner' @@ -23,15 +27,17 @@ interface FeedListProps { pages: number } }> - infiniteQuery?: UseInfiniteQueryResult<{ - page: FeedEntry[] - isDone: boolean - counts: { - total: number - pages: number - } - }> - filters?: Omit + infiniteQuery?: UseInfiniteQueryResult< + InfiniteData<{ + page: FeedEntry[] + isDone: boolean + counts: { + total: number + pages: number + } + }> + > + filters?: FeedFilters currentPage: number pageSize: number onPageChange: (page: number) => void @@ -40,7 +46,7 @@ interface FeedListProps { expandedIds?: string[] onExpandedChange?: (expandedIds: string[]) => void onViewModeChange?: (viewMode: 'table' | 'timeline') => void - onFiltersChange?: (filters: { sources?: string[] }) => void + onFiltersChange?: (filters: Partial) => void adminActions?: { onEdit?: (entry: FeedEntry) => void onToggleVisibility?: (entry: FeedEntry, isVisible: boolean) => void diff --git a/src/components/FeedPage.tsx b/src/components/FeedPage.tsx index 3e0b270e..2791265d 100644 --- a/src/components/FeedPage.tsx +++ b/src/components/FeedPage.tsx @@ -3,7 +3,7 @@ import { ReactNode } from 'react' import { useMounted } from '~/hooks/useMounted' import { Footer } from '~/components/Footer' import { FeedList } from '~/components/FeedList' -import { FeedFilters } from '~/components/FeedFilters' +import { FeedFilters as FeedFiltersComponent } from '~/components/FeedFilters' import { useFeedQuery } from '~/hooks/useFeedQuery' import { useFeedInfiniteQuery } from '~/hooks/useFeedInfiniteQuery' import { FeedEntry } from '~/components/FeedEntry' @@ -11,44 +11,14 @@ import { FEED_DEFAULTS } from '~/utils/feedDefaults' import { libraries } from '~/libraries' import { partners } from '~/utils/partners' import { useQuery } from '@tanstack/react-query' -import { getFeedFacetCountsQueryOptions } from '~/queries/feed' +import { + getFeedFacetCountsQueryOptions, + type FeedFilters, +} from '~/queries/feed' import { twMerge } from 'tailwind-merge' -export type LibraryId = - | 'start' - | 'router' - | 'query' - | 'table' - | 'form' - | 'virtual' - | 'ranger' - | 'store' - | 'pacer' - | 'db' - | 'config' - | 'react-charts' - | 'devtools' - | 'create-tsrouter-app' - -export type Category = - | 'release' - | 'announcement' - | 'blog' - | 'partner' - | 'update' - | 'other' - -export interface FeedFiltersState { - sources?: string[] - libraries?: LibraryId[] - categories?: Category[] - partners?: string[] - tags?: string[] - releaseLevels?: ('major' | 'minor' | 'patch')[] - includePrerelease?: boolean - featured?: boolean - search?: string -} +// Re-export FeedFilters as FeedFiltersState for backwards compatibility +export type FeedFiltersState = FeedFilters interface FeedPageProps { search: FeedFiltersState & { @@ -131,9 +101,8 @@ export function FeedPage({ page: effectiveFilters.page ?? 1, pageSize: effectiveFilters.pageSize ?? 50, filters: { - sources: normalizeFilter(effectiveFilters.sources), + entryTypes: normalizeFilter(effectiveFilters.entryTypes), libraries: normalizeFilter(effectiveFilters.libraries), - categories: normalizeFilter(effectiveFilters.categories), partners: normalizeFilter(effectiveFilters.partners), tags: normalizeFilter(effectiveFilters.tags), releaseLevels: normalizeFilter(effectiveFilters.releaseLevels), @@ -147,9 +116,8 @@ export function FeedPage({ const feedInfiniteQuery = useFeedInfiniteQuery({ pageSize: effectiveFilters.pageSize ?? 50, filters: { - sources: normalizeFilter(effectiveFilters.sources), + entryTypes: normalizeFilter(effectiveFilters.entryTypes), libraries: normalizeFilter(effectiveFilters.libraries), - categories: normalizeFilter(effectiveFilters.categories), partners: normalizeFilter(effectiveFilters.partners), tags: normalizeFilter(effectiveFilters.tags), releaseLevels: normalizeFilter(effectiveFilters.releaseLevels), @@ -163,9 +131,8 @@ export function FeedPage({ // Fetch facet counts based on current filters const facetCountsQuery = useQuery( getFeedFacetCountsQueryOptions({ - sources: effectiveFilters.sources, + entryTypes: effectiveFilters.entryTypes, libraries: effectiveFilters.libraries, - categories: effectiveFilters.categories as any, partners: effectiveFilters.partners, tags: effectiveFilters.tags, releaseLevels: effectiveFilters.releaseLevels as any, @@ -200,9 +167,8 @@ export function FeedPage({ page: FEED_DEFAULTS.page, pageSize: effectiveFilters.pageSize ?? FEED_DEFAULTS.pageSize, viewMode: effectiveFilters.viewMode ?? FEED_DEFAULTS.viewMode, - sources: undefined, + entryTypes: undefined, libraries: undefined, - categories: undefined, partners: undefined, tags: undefined, releaseLevels: undefined, @@ -272,8 +238,8 @@ export function FeedPage({ // Convert FeedFiltersState to FeedFilters format const feedFilters = useMemo( () => ({ + entryTypes: normalizeFilter(effectiveFilters.entryTypes), libraries: normalizeFilter(effectiveFilters.libraries), - categories: normalizeFilter(effectiveFilters.categories) as any, partners: normalizeFilter(effectiveFilters.partners), tags: normalizeFilter(effectiveFilters.tags), releaseLevels: normalizeFilter(effectiveFilters.releaseLevels) as any, @@ -283,8 +249,8 @@ export function FeedPage({ includeHidden: adminActions !== undefined, }), [ + effectiveFilters.entryTypes, effectiveFilters.libraries, - effectiveFilters.categories, effectiveFilters.partners, effectiveFilters.tags, effectiveFilters.releaseLevels, @@ -299,15 +265,14 @@ export function FeedPage({