diff --git a/README.md b/README.md index f6cfdd8f..4e2c926b 100644 --- a/README.md +++ b/README.md @@ -395,9 +395,10 @@ pnpm run dev ## **📚 Resources** -- [**Contributing Guidelines**](https://github.com/liblaber/ai/blob/main/CONTRIBUTING.md) - How to contribute to the project +- [**Contributing Guidelines**](https://github.com/liblaber/ai/blob/main/CONTRIBUTING.md) - How to contribute to the project - [Security & Privacy](docs/security-and-privacy.md) - [Configuration](docs/configuration.md) +- [SSO Setup Guide](docs/sso-setup.md) - Configure Single Sign-On with your identity provider - [Deploy on EC2 with HTTPS & Auto-Restart](docs/ec2.md) - [Getting Started](docs/getting-started.md) - [Features](docs/features.md) diff --git a/app/api/auth/google-config-status/route.ts b/app/api/auth/google-config-status/route.ts new file mode 100644 index 00000000..1bf7a86c --- /dev/null +++ b/app/api/auth/google-config-status/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { GoogleAuthService } from '~/lib/services/googleAuthService'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('google-config-status'); + +export async function GET() { + try { + const config = await GoogleAuthService.getGoogleOAuthConfig(); + const source = await GoogleAuthService.getConfigSource(); + const isAvailable = await GoogleAuthService.isGoogleOAuthAvailable(); + + const response: any = { + success: true, + config: { + source, + }, + isAvailable, + message: + source === 'environment' + ? 'Google OAuth configured via environment variables' + : source === 'onboarding' + ? 'Google OAuth configured via onboarding' + : 'Google OAuth not configured', + }; + + // Include credentials if they're available (for frontend to populate form) + if (isAvailable && config.clientId && config.clientSecret) { + response.credentials = { + clientId: config.clientId, + clientSecret: config.clientSecret, + }; + } + + return NextResponse.json(response); + } catch (error) { + logger.error('Error getting Google OAuth config status:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to get Google OAuth configuration status', + }, + { status: 500 }, + ); + } +} diff --git a/app/api/auth/google/callback/route.ts b/app/api/auth/google/callback/route.ts new file mode 100644 index 00000000..1ff88689 --- /dev/null +++ b/app/api/auth/google/callback/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('google-oauth-callback'); + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + const error = searchParams.get('error'); + + // Handle OAuth errors + if (error) { + logger.error('Google OAuth error:', error); + + const errorDescription = searchParams.get('error_description') || 'Unknown error'; + + // Redirect back to onboarding with error + const redirectUrl = new URL('/onboarding', request.url); + redirectUrl.searchParams.set('error', `Google OAuth error: ${errorDescription}`); + + return NextResponse.redirect(redirectUrl.toString()); + } + + // Validate required parameters + if (!code) { + logger.error('No authorization code received from Google'); + + const redirectUrl = new URL('/onboarding', request.url); + redirectUrl.searchParams.set('error', 'No authorization code received from Google'); + + return NextResponse.redirect(redirectUrl.toString()); + } + + // For now, we'll just redirect back to onboarding with success + // In a full implementation, you would: + // 1. Exchange the code for tokens + // 2. Get user info from Google + // 3. Create the admin user + // 4. Set up the session + + logger.info('Google OAuth callback successful, redirecting to onboarding'); + + const redirectUrl = new URL('/onboarding', request.url); + redirectUrl.searchParams.set('google_oauth_success', 'true'); + redirectUrl.searchParams.set('code', code); + + return NextResponse.redirect(redirectUrl.toString()); + } catch (error) { + logger.error('Error in Google OAuth callback:', error); + + const redirectUrl = new URL('/onboarding', request.url); + redirectUrl.searchParams.set('error', 'OAuth callback failed'); + + return NextResponse.redirect(redirectUrl.toString()); + } +} diff --git a/app/api/onboarding/auth-config/route.ts b/app/api/onboarding/auth-config/route.ts new file mode 100644 index 00000000..4e885985 --- /dev/null +++ b/app/api/onboarding/auth-config/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '~/lib/prisma'; +import { createScopedLogger } from '~/utils/logger'; +import { + type OnboardingStepResponse, + type OnboardingSuccessResponse, + type OnboardingErrorResponse, + AUTH_CONFIG_STEP_REQUEST_SCHEMA, +} from '~/types/onboarding'; + +const logger = createScopedLogger('onboarding-auth-config-api'); + +export async function POST(request: NextRequest): Promise> { + try { + const body = await request.json(); + + // Debug logging + logger.info('Received auth-config step request:', JSON.stringify(body, null, 2)); + + // Validate request body structure using Zod + const validationResult = AUTH_CONFIG_STEP_REQUEST_SCHEMA.safeParse(body); + + if (!validationResult.success) { + logger.error('Validation failed:', validationResult.error.errors); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: `Invalid request format: ${validationResult.error.errors.map((e) => e.message).join(', ')}`, + }; + + return NextResponse.json(errorResponse, { status: 400 }); + } + + const { adminData, ssoConfig, googleOAuthConfig } = validationResult.data; + + // Validate password if using password auth + if (adminData.password && adminData.confirmPassword) { + if (adminData.password !== adminData.confirmPassword) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Passwords do not match', + }; + return NextResponse.json(errorResponse, { status: 400 }); + } + } + + // Check if onboarding progress already exists + let progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, // For initial setup, userId is null + }); + + if (progress) { + // Update existing progress + progress = await prisma.onboardingProgress.update({ + where: { id: progress.id }, + data: { + adminData: adminData as any, + ssoConfig: ssoConfig as any, + googleOAuthConfig: googleOAuthConfig as any, + currentStep: 'complete', + updatedAt: new Date(), + }, + }); + } else { + // Create new progress + progress = await prisma.onboardingProgress.create({ + data: { + adminData: adminData as any, + ssoConfig: ssoConfig as any, + googleOAuthConfig: googleOAuthConfig as any, + currentStep: 'complete', + }, + }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Authentication configuration saved successfully', + currentStep: 'complete', + }; + + logger.info('Auth-config step completed successfully'); + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error in auth-config step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to save authentication configuration', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} + +export async function GET(): Promise> { + try { + // Get current auth-config step progress + const progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, + }); + + if (!progress) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'No onboarding progress found', + }; + return NextResponse.json(errorResponse, { status: 404 }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Auth-config step progress retrieved', + currentStep: progress.currentStep as any, + }; + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error retrieving auth-config step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to retrieve auth-config step progress', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} diff --git a/app/api/onboarding/auth/route.ts b/app/api/onboarding/auth/route.ts new file mode 100644 index 00000000..1ef3d569 --- /dev/null +++ b/app/api/onboarding/auth/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '~/lib/prisma'; +import { createScopedLogger } from '~/utils/logger'; +import { + type OnboardingStepResponse, + type OnboardingSuccessResponse, + type OnboardingErrorResponse, + AUTH_STEP_REQUEST_SCHEMA, +} from '~/types/onboarding'; + +const logger = createScopedLogger('onboarding-auth-api'); + +export async function POST(request: NextRequest): Promise> { + try { + const body = await request.json(); + + // Debug logging + logger.info('Received auth step request:', JSON.stringify(body, null, 2)); + + // Validate request body structure using Zod + const validationResult = AUTH_STEP_REQUEST_SCHEMA.safeParse(body); + + if (!validationResult.success) { + logger.error('Validation failed:', validationResult.error.errors); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: `Invalid request format: ${validationResult.error.errors.map((e) => e.message).join(', ')}`, + }; + + return NextResponse.json(errorResponse, { status: 400 }); + } + + const { authMethod } = validationResult.data; + + // Check if onboarding progress already exists + let progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, // For initial setup, userId is null + }); + + if (progress) { + // Update existing progress + progress = await prisma.onboardingProgress.update({ + where: { id: progress.id }, + data: { + authMethod, + currentStep: 'auth-config', + updatedAt: new Date(), + }, + }); + } else { + // Create new progress + progress = await prisma.onboardingProgress.create({ + data: { + authMethod, + currentStep: 'auth-config', + }, + }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Authentication method saved successfully', + currentStep: 'auth-config', + }; + + logger.info('Auth step completed successfully'); + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error in auth step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to save authentication method', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} + +export async function GET(): Promise> { + try { + // Get current auth step progress + const progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, + }); + + if (!progress) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'No onboarding progress found', + }; + return NextResponse.json(errorResponse, { status: 404 }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Auth step progress retrieved', + currentStep: progress.currentStep as any, + }; + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error retrieving auth step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to retrieve auth step progress', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} diff --git a/app/api/onboarding/complete/route.ts b/app/api/onboarding/complete/route.ts new file mode 100644 index 00000000..3eb52c23 --- /dev/null +++ b/app/api/onboarding/complete/route.ts @@ -0,0 +1,210 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { userService } from '~/lib/services/userService'; +import { prisma } from '~/lib/prisma'; +import { createScopedLogger } from '~/utils/logger'; +import { + type OnboardingCompleteResponse, + type OnboardingSuccessResponse, + type OnboardingErrorResponse, + ONBOARDING_REQUEST_SCHEMA, +} from '~/types/onboarding'; + +const logger = createScopedLogger('onboarding-api'); + +export async function POST(request: NextRequest): Promise> { + try { + const body = await request.json(); + + // Debug logging + logger.info('Received onboarding request:', JSON.stringify(body, null, 2)); + + // Validate request body structure using Zod + const validationResult = ONBOARDING_REQUEST_SCHEMA.safeParse(body); + + if (!validationResult.success) { + logger.error('Validation failed:', validationResult.error.errors); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: `Invalid request format: ${validationResult.error.errors.map((e) => e.message).join(', ')}`, + }; + + return NextResponse.json(errorResponse, { status: 400 }); + } + + const { authMethod, adminData, ssoConfig, googleOAuthConfig, telemetryConsent } = validationResult.data; + + // Validate required fields + if (!adminData.name || !adminData.email) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Name and email are required', + }; + return NextResponse.json(errorResponse, { status: 400 }); + } + + // Validate password if using password auth + if (authMethod === 'password') { + if (!adminData.password || !adminData.confirmPassword) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Password and confirmation are required for password authentication', + }; + return NextResponse.json(errorResponse, { status: 400 }); + } + + if (adminData.password !== adminData.confirmPassword) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Passwords do not match', + }; + return NextResponse.json(errorResponse, { status: 400 }); + } + + if (adminData.password.length < 8) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Password must be at least 8 characters long', + }; + return NextResponse.json(errorResponse, { status: 400 }); + } + } + + // Validate SSO config if using SSO + if (authMethod === 'sso' && ssoConfig) { + if (!ssoConfig.hostUrl || !ssoConfig.clientId || !ssoConfig.clientSecret) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'SSO configuration is incomplete', + }; + return NextResponse.json(errorResponse, { status: 400 }); + } + } + + // Validate Google OAuth config if using Google + if (authMethod === 'google' && googleOAuthConfig) { + if (!googleOAuthConfig.clientId || !googleOAuthConfig.clientSecret) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Google OAuth configuration is incomplete', + }; + return NextResponse.json(errorResponse, { status: 400 }); + } + } + + // Check if application is already set up + const isAlreadySetUp = await userService.isApplicationSetUp(); + + if (isAlreadySetUp) { + // If already set up, just mark onboarding as complete and return success + const progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, // For initial setup, userId is null + }); + + if (progress) { + await prisma.onboardingProgress.update({ + where: { id: progress.id }, + data: { + currentStep: 'complete', + completedAt: new Date(), + updatedAt: new Date(), + }, + }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Onboarding already completed', + userId: undefined, // No new user created + }; + + return NextResponse.json(successResponse); + } + + // Create admin user + let adminUser; + + if (authMethod === 'password') { + // For password auth, we need to create the user through the auth system + // This is a simplified approach - in a real implementation, you'd want to + // use the proper auth flow + adminUser = await prisma.user.create({ + data: { + name: adminData.name, + email: adminData.email, + emailVerified: true, + isAnonymous: false, + telemetryEnabled: telemetryConsent, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + } else { + // For OAuth/SSO, create user without password + adminUser = await prisma.user.create({ + data: { + name: adminData.name, + email: adminData.email, + emailVerified: true, + isAnonymous: false, + telemetryEnabled: telemetryConsent, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + } + + // Grant admin access + await userService.grantSystemAdminAccess(adminUser.id); + + // If SSO is configured, create SSO provider + if (authMethod === 'sso' && ssoConfig) { + await prisma.ssoProvider.create({ + data: { + providerId: 'custom-sso', + friendlyName: 'Custom SSO', + issuer: ssoConfig.hostUrl, + domain: new URL(ssoConfig.hostUrl).hostname, + oidcConfig: JSON.stringify({ + clientId: ssoConfig.clientId, + clientSecret: ssoConfig.clientSecret, + scopes: ssoConfig.scopes, + }), + }, + }); + } + + // If Google OAuth is configured, log the configuration + if (authMethod === 'google' && googleOAuthConfig) { + logger.info('Google OAuth configured with Client ID:', googleOAuthConfig.clientId); + + // The Google OAuth configuration is now stored in the database + // and will be picked up dynamically by the auth service + // No need to restart the application - the configuration is loaded dynamically + } + + logger.info(`Onboarding completed for admin user: ${adminUser.email}`); + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Onboarding completed successfully', + userId: adminUser.id, + }; + + const response = NextResponse.json(successResponse); + + // Set a header to indicate onboarding is complete (for cache invalidation) + response.headers.set('x-onboarding-complete', 'true'); + + return response; + } catch (error) { + logger.error('Error completing onboarding:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: error instanceof Error ? error.message : 'An error occurred during onboarding', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} diff --git a/app/api/onboarding/datasource/route.ts b/app/api/onboarding/datasource/route.ts new file mode 100644 index 00000000..ec355bef --- /dev/null +++ b/app/api/onboarding/datasource/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '~/lib/prisma'; +import { createScopedLogger } from '~/utils/logger'; +import { + type OnboardingStepResponse, + type OnboardingSuccessResponse, + type OnboardingErrorResponse, + DATASOURCE_STEP_REQUEST_SCHEMA, +} from '~/types/onboarding'; + +const logger = createScopedLogger('onboarding-datasource-api'); + +export async function POST(request: NextRequest): Promise> { + try { + const body = await request.json(); + + // Debug logging + logger.info('Received datasource step request:', JSON.stringify(body, null, 2)); + + // Validate request body structure using Zod + const validationResult = DATASOURCE_STEP_REQUEST_SCHEMA.safeParse(body); + + if (!validationResult.success) { + logger.error('Validation failed:', validationResult.error.errors); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: `Invalid request format: ${validationResult.error.errors.map((e) => e.message).join(', ')}`, + }; + + return NextResponse.json(errorResponse, { status: 400 }); + } + + const { datasourceConfig } = validationResult.data; + + // Check if onboarding progress already exists + let progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, // For initial setup, userId is null + }); + + if (progress) { + // Update existing progress + progress = await prisma.onboardingProgress.update({ + where: { id: progress.id }, + data: { + datasourceConfig: datasourceConfig as any, + currentStep: 'users', + updatedAt: new Date(), + }, + }); + } else { + // Create new progress + progress = await prisma.onboardingProgress.create({ + data: { + datasourceConfig: datasourceConfig as any, + currentStep: 'users', + }, + }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Data source configuration saved successfully', + currentStep: 'users', + }; + + logger.info('Datasource step completed successfully'); + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error in datasource step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to save data source configuration', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} + +export async function GET(): Promise> { + try { + // Get current datasource step progress + const progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, + }); + + if (!progress) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'No onboarding progress found', + }; + return NextResponse.json(errorResponse, { status: 404 }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Datasource step progress retrieved', + currentStep: progress.currentStep as any, + }; + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error retrieving datasource step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to retrieve datasource step progress', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} diff --git a/app/api/onboarding/google-oauth/authorize/route.ts b/app/api/onboarding/google-oauth/authorize/route.ts new file mode 100644 index 00000000..8a98a417 --- /dev/null +++ b/app/api/onboarding/google-oauth/authorize/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createScopedLogger } from '~/utils/logger'; +import { GoogleAuthService } from '~/lib/services/googleAuthService'; + +const logger = createScopedLogger('onboarding-google-oauth-authorize'); + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const callback = searchParams.get('callback'); + + if (!callback) { + return NextResponse.json({ success: false, error: 'Callback URL is required' }, { status: 400 }); + } + + // Get Google OAuth configuration + const googleConfig = await GoogleAuthService.getGoogleOAuthConfig(); + + if (!googleConfig.enabled || !googleConfig.clientId || !googleConfig.clientSecret) { + return NextResponse.json({ success: false, error: 'Google OAuth is not configured' }, { status: 400 }); + } + + // Create Google OAuth authorization URL using Better Auth's standard callback + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + authUrl.searchParams.set('client_id', googleConfig.clientId); + authUrl.searchParams.set('redirect_uri', `${process.env.BASE_URL}/api/auth/callback/google`); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', 'openid email profile'); + authUrl.searchParams.set('state', encodeURIComponent(callback)); // Store callback in state + + logger.info('Redirecting to Google OAuth for onboarding'); + + // Redirect to Google OAuth + return NextResponse.redirect(authUrl.toString()); + } catch (error) { + logger.error('Error in Google OAuth authorize:', error); + return NextResponse.json( + { success: false, error: 'An unexpected error occurred during Google OAuth authorization.' }, + { status: 500 }, + ); + } +} diff --git a/app/api/onboarding/google-oauth/callback/route.ts b/app/api/onboarding/google-oauth/callback/route.ts new file mode 100644 index 00000000..4950ade9 --- /dev/null +++ b/app/api/onboarding/google-oauth/callback/route.ts @@ -0,0 +1,148 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createScopedLogger } from '~/utils/logger'; +import { GoogleAuthService } from '~/lib/services/googleAuthService'; + +const logger = createScopedLogger('onboarding-google-oauth-callback'); + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const error = searchParams.get('error'); + + if (error) { + logger.error('Google OAuth error:', error); + return NextResponse.redirect(`/onboarding?error=${encodeURIComponent(`Google OAuth error: ${error}`)}`); + } + + if (!code || !state) { + logger.error('Missing code or state parameter'); + return NextResponse.redirect('/onboarding?error=' + encodeURIComponent('Invalid OAuth response')); + } + + // Decode the callback URL from state + const callbackURL = decodeURIComponent(state); + + // Get Google OAuth configuration + const googleConfig = await GoogleAuthService.getGoogleOAuthConfig(); + + if (!googleConfig.enabled || !googleConfig.clientId || !googleConfig.clientSecret) { + logger.error('Google OAuth is not configured'); + return NextResponse.redirect('/onboarding?error=' + encodeURIComponent('Google OAuth is not configured')); + } + + // Exchange authorization code for access 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: googleConfig.clientId, + client_secret: googleConfig.clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: `${process.env.BASE_URL}/api/auth/callback/google`, + }), + }); + + const tokenData = (await tokenResponse.json()) as { access_token?: string; error?: string }; + + if (!tokenResponse.ok || tokenData.error) { + logger.error('Failed to exchange code for token:', tokenData); + return NextResponse.redirect('/onboarding?error=' + encodeURIComponent('Failed to authenticate with Google')); + } + + // Get user info from Google + const userResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + const userData = (await userResponse.json()) as { + id?: string; + name?: string; + email?: string; + picture?: string; + error?: string; + }; + + if (!userResponse.ok || userData.error) { + logger.error('Failed to get user info:', userData); + return NextResponse.redirect( + '/onboarding?error=' + encodeURIComponent('Failed to get user information from Google'), + ); + } + + logger.info('Google OAuth successful for user:', userData.email); + + // Store the user data in the onboarding progress + await storeGoogleUserData(userData); + + // Redirect back to the onboarding page with the user data + const redirectUrl = new URL(callbackURL, process.env.BASE_URL); + redirectUrl.searchParams.set( + 'googleUser', + JSON.stringify({ + id: userData.id, + email: userData.email, + name: userData.name, + picture: userData.picture, + }), + ); + + return NextResponse.redirect(redirectUrl.toString()); + } catch (error) { + logger.error('Error in Google OAuth callback:', error); + return NextResponse.redirect( + '/onboarding?error=' + encodeURIComponent('An unexpected error occurred during Google authentication'), + ); + } +} + +async function storeGoogleUserData(userData: any) { + try { + const { prisma } = await import('~/lib/prisma'); + + // Find existing onboarding progress + const progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, + orderBy: { updatedAt: 'desc' }, + }); + + const googleUserData = { + id: userData.id, + email: userData.email, + name: userData.name, + picture: userData.picture, + }; + + if (progress) { + // Update existing progress + await prisma.onboardingProgress.update({ + where: { id: progress.id }, + data: { + adminData: { + ...((progress.adminData as any) || {}), + googleUser: googleUserData, + } as any, + updatedAt: new Date(), + }, + }); + } else { + // Create new progress + await prisma.onboardingProgress.create({ + data: { + adminData: { + googleUser: googleUserData, + } as any, + currentStep: 'auth-config', + }, + }); + } + } catch (error) { + logger.error('Error storing Google user data:', error); + } +} diff --git a/app/api/onboarding/google-user-info/route.ts b/app/api/onboarding/google-user-info/route.ts new file mode 100644 index 00000000..6c680d27 --- /dev/null +++ b/app/api/onboarding/google-user-info/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('google-user-info-api'); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { code, clientId, clientSecret } = body as { code?: string; clientId?: string; clientSecret?: string }; + + // Validate required fields + if (!code || !clientId || !clientSecret) { + return NextResponse.json( + { success: false, error: 'Authorization code, Client ID, and Client Secret are required' }, + { status: 400 }, + ); + } + + logger.info('Exchanging Google OAuth code for user info'); + + // Exchange authorization code for access 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, + code, + grant_type: 'authorization_code', + redirect_uri: `${request.nextUrl.origin}/api/auth/google/callback`, + }), + }); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json(); + logger.error('Failed to exchange code for token:', errorData); + + return NextResponse.json( + { + success: false, + error: `Failed to exchange authorization code: ${(errorData as any).error_description || (errorData as any).error}`, + }, + { status: 400 }, + ); + } + + const tokenData = (await tokenResponse.json()) as { access_token?: string }; + const { access_token: accessToken } = tokenData; + + // Get user info from Google + const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!userInfoResponse.ok) { + logger.error('Failed to get user info from Google'); + return NextResponse.json( + { success: false, error: 'Failed to get user information from Google' }, + { status: 400 }, + ); + } + + const userInfo = (await userInfoResponse.json()) as { + name?: string; + email?: string; + picture?: string; + id?: string; + }; + + logger.info('Successfully retrieved Google user info:', { email: userInfo.email, name: userInfo.name }); + + return NextResponse.json({ + success: true, + user: { + name: userInfo.name || '', + email: userInfo.email || '', + picture: userInfo.picture || '', + id: userInfo.id || '', + }, + }); + } catch (error) { + logger.error('Error getting Google user info:', error); + return NextResponse.json( + { + success: false, + error: `Failed to get user information: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/onboarding/llm/route.ts b/app/api/onboarding/llm/route.ts new file mode 100644 index 00000000..45994425 --- /dev/null +++ b/app/api/onboarding/llm/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '~/lib/prisma'; +import { createScopedLogger } from '~/utils/logger'; +import { + type OnboardingStepResponse, + type OnboardingSuccessResponse, + type OnboardingErrorResponse, + LLM_STEP_REQUEST_SCHEMA, +} from '~/types/onboarding'; + +const logger = createScopedLogger('onboarding-llm-api'); + +export async function POST(request: NextRequest): Promise> { + try { + const body = await request.json(); + + // Debug logging + logger.info('Received LLM step request:', JSON.stringify(body, null, 2)); + + // Validate request body structure using Zod + const validationResult = LLM_STEP_REQUEST_SCHEMA.safeParse(body); + + if (!validationResult.success) { + logger.error('Validation failed:', validationResult.error.errors); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: `Invalid request format: ${validationResult.error.errors.map((e) => e.message).join(', ')}`, + }; + + return NextResponse.json(errorResponse, { status: 400 }); + } + + const { llmConfig } = validationResult.data; + + // Check if onboarding progress already exists + let progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, // For initial setup, userId is null + }); + + if (progress) { + // Update existing progress + progress = await prisma.onboardingProgress.update({ + where: { id: progress.id }, + data: { + llmConfig: llmConfig as any, + currentStep: 'datasource', + updatedAt: new Date(), + }, + }); + } else { + // Create new progress + progress = await prisma.onboardingProgress.create({ + data: { + llmConfig: llmConfig as any, + currentStep: 'datasource', + }, + }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'LLM configuration saved successfully', + currentStep: 'datasource', + }; + + logger.info('LLM step completed successfully'); + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error in LLM step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to save LLM configuration', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} + +export async function GET(): Promise> { + try { + // Get current LLM step progress + const progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, + }); + + if (!progress) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'No onboarding progress found', + }; + return NextResponse.json(errorResponse, { status: 404 }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'LLM step progress retrieved', + currentStep: progress.currentStep as any, + }; + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error retrieving LLM step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to retrieve LLM step progress', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} diff --git a/app/api/onboarding/save-google-oauth/route.ts b/app/api/onboarding/save-google-oauth/route.ts new file mode 100644 index 00000000..95345309 --- /dev/null +++ b/app/api/onboarding/save-google-oauth/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createScopedLogger } from '~/utils/logger'; +import { z } from 'zod'; +import { prisma } from '~/lib/prisma'; + +const logger = createScopedLogger('save-google-oauth'); + +const SAVE_GOOGLE_OAUTH_SCHEMA = z.object({ + clientId: z.string().min(1, 'Client ID is required'), + clientSecret: z.string().min(1, 'Client Secret is required'), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const validationResult = SAVE_GOOGLE_OAUTH_SCHEMA.safeParse(body); + + if (!validationResult.success) { + return NextResponse.json( + { + success: false, + error: `Invalid request format: ${validationResult.error.errors.map((e) => e.message).join(', ')}`, + }, + { status: 400 }, + ); + } + + const { clientId, clientSecret } = validationResult.data; + + logger.info('Saving Google OAuth credentials for Client ID:', clientId); + + // Find existing onboarding progress or create new one + let progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, // For initial setup + orderBy: { updatedAt: 'desc' }, + }); + + const googleOAuthConfig = { + clientId, + clientSecret, + }; + + if (progress) { + // Update existing progress + progress = await prisma.onboardingProgress.update({ + where: { id: progress.id }, + data: { + googleOAuthConfig: googleOAuthConfig as any, + updatedAt: new Date(), + }, + }); + } else { + // Create new progress + progress = await prisma.onboardingProgress.create({ + data: { + googleOAuthConfig: googleOAuthConfig as any, + currentStep: 'auth-config', + }, + }); + } + + logger.info('Google OAuth credentials saved successfully'); + + return NextResponse.json({ + success: true, + message: 'Google OAuth credentials saved successfully', + }); + } catch (error) { + logger.error('Error saving Google OAuth credentials:', error); + return NextResponse.json( + { success: false, error: 'An unexpected error occurred while saving credentials.' }, + { status: 500 }, + ); + } +} diff --git a/app/api/onboarding/status/route.ts b/app/api/onboarding/status/route.ts new file mode 100644 index 00000000..ca9c639e --- /dev/null +++ b/app/api/onboarding/status/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { userService } from '~/lib/services/userService'; +import { createScopedLogger } from '~/utils/logger'; +import type { OnboardingStatusResponse } from '~/types/onboarding'; +import { ONBOARDING_STATUS_RESPONSE_SCHEMA } from '~/types/onboarding'; + +const logger = createScopedLogger('onboarding-status-api'); + +export async function GET(): Promise> { + try { + const isSetUp = await userService.isApplicationSetUp(); + + const response: OnboardingStatusResponse = { + isSetUp, + }; + + // Validate response structure + const validationResult = ONBOARDING_STATUS_RESPONSE_SCHEMA.safeParse(response); + + if (!validationResult.success) { + logger.error('Invalid response structure:', validationResult.error); + } + + return NextResponse.json(response); + } catch (error) { + logger.error('Error checking onboarding status:', error); + + const errorResponse: OnboardingStatusResponse = { + error: 'Failed to check onboarding status', + isSetUp: false, + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} diff --git a/app/api/onboarding/users/route.ts b/app/api/onboarding/users/route.ts new file mode 100644 index 00000000..73e2b2a2 --- /dev/null +++ b/app/api/onboarding/users/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '~/lib/prisma'; +import { createScopedLogger } from '~/utils/logger'; +import { + type OnboardingStepResponse, + type OnboardingSuccessResponse, + type OnboardingErrorResponse, + USERS_STEP_REQUEST_SCHEMA, +} from '~/types/onboarding'; + +const logger = createScopedLogger('onboarding-users-api'); + +export async function POST(request: NextRequest): Promise> { + try { + const body = await request.json(); + + // Debug logging + logger.info('Received users step request:', JSON.stringify(body, null, 2)); + + // Validate request body structure using Zod + const validationResult = USERS_STEP_REQUEST_SCHEMA.safeParse(body); + + if (!validationResult.success) { + logger.error('Validation failed:', validationResult.error.errors); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: `Invalid request format: ${validationResult.error.errors.map((e) => e.message).join(', ')}`, + }; + + return NextResponse.json(errorResponse, { status: 400 }); + } + + const { usersConfig } = validationResult.data; + + // Check if onboarding progress already exists + let progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, // For initial setup, userId is null + }); + + if (progress) { + // Update existing progress + progress = await prisma.onboardingProgress.update({ + where: { id: progress.id }, + data: { + usersConfig: usersConfig as any, + currentStep: 'complete', + updatedAt: new Date(), + }, + }); + } else { + // Create new progress + progress = await prisma.onboardingProgress.create({ + data: { + usersConfig: usersConfig as any, + currentStep: 'complete', + }, + }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Users configuration saved successfully', + currentStep: 'complete', + }; + + logger.info('Users step completed successfully'); + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error in users step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to save users configuration', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} + +export async function GET(): Promise> { + try { + // Get current users step progress + const progress = await prisma.onboardingProgress.findFirst({ + where: { userId: null }, + }); + + if (!progress) { + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'No onboarding progress found', + }; + return NextResponse.json(errorResponse, { status: 404 }); + } + + const successResponse: OnboardingSuccessResponse = { + success: true, + message: 'Users step progress retrieved', + currentStep: progress.currentStep as any, + }; + + return NextResponse.json(successResponse); + } catch (error) { + logger.error('Error retrieving users step:', error); + + const errorResponse: OnboardingErrorResponse = { + success: false, + error: 'Failed to retrieve users step progress', + }; + + return NextResponse.json(errorResponse, { status: 500 }); + } +} diff --git a/app/api/onboarding/validate-google-oauth/route.ts b/app/api/onboarding/validate-google-oauth/route.ts new file mode 100644 index 00000000..bcfe4bc7 --- /dev/null +++ b/app/api/onboarding/validate-google-oauth/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createScopedLogger } from '~/utils/logger'; +import { z } from 'zod'; + +const logger = createScopedLogger('google-oauth-validation'); + +/** + * Google OAuth Credential Validation + * + * This endpoint validates Google OAuth credentials by hitting Google's token endpoint + * with a dummy refresh token. Google checks client credentials first: + * + * - If client_id/secret are wrong → {"error":"invalid_client"} (HTTP 401) + * - If client_id/secret are correct but refresh token is fake → {"error":"invalid_grant"} (HTTP 400) + * + * This is the simplest way to validate credentials without doing a full browser auth flow, + * since Google doesn't support client_credentials grant for user-data APIs. + */ + +const VALIDATE_GOOGLE_OAUTH_SCHEMA = z.object({ + clientId: z.string().min(1, 'Client ID is required'), + clientSecret: z.string().min(1, 'Client Secret is required'), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate request body + const validationResult = VALIDATE_GOOGLE_OAUTH_SCHEMA.safeParse(body); + + if (!validationResult.success) { + logger.error('Validation failed:', validationResult.error.errors); + return NextResponse.json( + { + success: false, + error: `Invalid request format: ${validationResult.error.errors.map((e) => e.message).join(', ')}`, + }, + { status: 400 }, + ); + } + + const { clientId, clientSecret } = validationResult.data; + + logger.info('Validating Google OAuth credentials for Client ID:', clientId); + + // Test the credentials by making a request to Google's token endpoint + // We'll use a fake refresh token to test if the credentials are valid + // This will return an error, but the error type will tell us if the credentials are valid + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: 'THIS_IS_FAKE_REFRESH_TOKEN_FOR_VALIDATION', + client_id: clientId, + client_secret: clientSecret, + }), + }); + + const responseData = (await response.json()) as { error?: string; error_description?: string }; + + // Check the response to determine if credentials are valid + // According to Google's OAuth 2.0 spec: + // - {"error":"invalid_client"} → credentials are invalid (HTTP 401) + // - {"error":"invalid_grant"} → credentials are valid (HTTP 400) + + if (responseData.error === 'invalid_client') { + // Client credentials are invalid + return NextResponse.json({ + success: false, + error: 'Invalid Google OAuth credentials. Please check your Client ID and Client Secret.', + valid: false, + }); + } else if (responseData.error === 'invalid_grant') { + // Client credentials are valid, but refresh token is fake (expected) + return NextResponse.json({ + success: true, + message: 'Google OAuth credentials are valid', + valid: true, + }); + } + + // If we get here, we received an unexpected response + // Log the response for debugging + logger.warn('Unexpected Google OAuth response:', { + status: response.status, + error: responseData.error, + error_description: responseData.error_description, + }); + + // Check if the client_id format is valid as a fallback + if (!clientId.includes('.apps.googleusercontent.com')) { + return NextResponse.json({ + success: false, + error: 'Invalid Client ID format. It should end with .apps.googleusercontent.com', + valid: false, + }); + } + + // If format is correct but response is unexpected, provide a cautious response + return NextResponse.json({ + success: false, + error: `Unexpected response from Google OAuth: ${responseData.error || 'Unknown error'}. Please verify your credentials.`, + valid: false, + }); + } catch (error) { + logger.error('Error validating Google OAuth credentials:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to validate Google OAuth credentials. Please check your internet connection and try again.', + valid: false, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/test-middleware/route.ts b/app/api/test-middleware/route.ts new file mode 100644 index 00000000..1e027ddb --- /dev/null +++ b/app/api/test-middleware/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ + message: 'Middleware test endpoint - if you can see this, middleware is working', + timestamp: new Date().toISOString(), + }); +} diff --git a/app/auth/auth-config.ts b/app/auth/auth-config.ts index f94d0096..8f83a107 100644 --- a/app/auth/auth-config.ts +++ b/app/auth/auth-config.ts @@ -6,6 +6,7 @@ import { env } from '~/env'; import { createAuthMiddleware } from 'better-auth/plugins'; import { userService } from '~/lib/services/userService'; import { inviteService } from '~/lib/services/inviteService'; +import { GoogleAuthService } from '~/lib/services/googleAuthService'; const { BASE_URL, @@ -24,9 +25,6 @@ const isPremiumLicense = LICENSE_KEY === 'premium'; const hasGoogleOAuth = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET); const hasOIDCSSO = !!(OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_CLIENT_SECRET && OIDC_DOMAIN && OIDC_PROVIDER_ID); -// Enable Google OAuth only for premium licenses with proper configuration -const enableGoogleAuth = isPremiumLicense && hasGoogleOAuth; - // Enable OIDC SSO when properly configured (regardless of license) const enableOIDCSSO = hasOIDCSSO; @@ -41,12 +39,7 @@ if (isPremiumLicense && !hasGoogleOAuth && !hasOIDCSSO) { ); } -if (enableGoogleAuth && (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET)) { - throw new Error( - 'Google OAuth is enabled but required secrets are missing. ' + - 'Please ensure GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are properly configured.', - ); -} +// Google OAuth validation is now handled dynamically in createAuthConfig() if (enableOIDCSSO && (!OIDC_ISSUER || !OIDC_CLIENT_ID || !OIDC_CLIENT_SECRET || !OIDC_DOMAIN || !OIDC_PROVIDER_ID)) { throw new Error( @@ -55,124 +48,143 @@ if (enableOIDCSSO && (!OIDC_ISSUER || !OIDC_CLIENT_ID || !OIDC_CLIENT_SECRET || ); } -export const auth = betterAuth({ - database: prismaAdapter(prisma, { - provider: 'postgresql', - }), - plugins: [ - // Add SSO plugin for OIDC support - ...(enableOIDCSSO - ? [ - sso({ - provisionUser: async ({ user }) => { - // Provision user when they sign in with SSO - - const { email } = user; - - if (!email) { - return; - } - - // Skip anonymous users — we only want to grant system admin to the first - // non-anonymous user. - if (user.isAnonymous) { - return; - } - - await grantSystemAdminAccess(user.id).catch(); - - // Check for pending invites and auto-accept them - try { - const pendingInvite = await inviteService.getInviteByEmail(email); - - if (pendingInvite) { - await inviteService.acceptInvite(pendingInvite.id, user.id); +// Create auth configuration dynamically +async function createAuthConfig() { + // Get Google OAuth configuration dynamically + const googleConfig = await GoogleAuthService.getGoogleOAuthConfig(); + + return betterAuth({ + database: prismaAdapter(prisma, { + provider: 'postgresql', + }), + plugins: [ + // Add SSO plugin for OIDC support + ...(enableOIDCSSO + ? [ + sso({ + provisionUser: async ({ user }) => { + // Provision user when they sign in with SSO + + const { email } = user; + + if (!email) { + return; + } + + // Skip anonymous users — we only want to grant system admin to the first + // non-anonymous user. + if (user.isAnonymous) { + return; } - } catch { - // Don't fail the auth flow if invite acceptance fails - } - }, - }), - ] - : []), - ], - emailAndPassword: { - enabled: enableEmailPassword, // Enable email/password auth for anonymous user - }, - socialProviders: { - google: { - clientId: GOOGLE_CLIENT_ID!, - clientSecret: GOOGLE_CLIENT_SECRET!, - enabled: enableGoogleAuth, + + // Only grant admin access if the application is not set up yet + const isApplicationSetUp = await userService.isApplicationSetUp(); + + if (!isApplicationSetUp) { + await grantSystemAdminAccess(user.id).catch(); + } + + // Check for pending invites and auto-accept them + try { + const pendingInvite = await inviteService.getInviteByEmail(email); + + if (pendingInvite) { + await inviteService.acceptInvite(pendingInvite.id, user.id); + } + } catch { + // Don't fail the auth flow if invite acceptance fails + } + }, + }), + ] + : []), + ], + emailAndPassword: { + enabled: enableEmailPassword, // Enable email/password auth for anonymous user }, - }, - baseURL: BASE_URL, - trustedOrigins: [BASE_URL], - advanced: { - database: { - generateId: false, // Assumes a database handles ID generation + socialProviders: { + google: { + clientId: googleConfig.clientId, + clientSecret: googleConfig.clientSecret, + enabled: googleConfig.enabled, + }, }, - }, - hooks: { - after: createAuthMiddleware(async (ctx) => { - if (ctx.path.endsWith('callback/liblab')) { - return; - } - - // Handle OAuth/SSO callbacks (Google OAuth or OIDC SSO) - if (ctx.path.startsWith('/callback/')) { - const newSession = ctx.context.newSession; + baseURL: BASE_URL, + trustedOrigins: [BASE_URL], + advanced: { + database: { + generateId: false, // Assumes a database handles ID generation + }, + }, + hooks: { + after: createAuthMiddleware(async (ctx) => { + if (ctx.path.endsWith('callback/liblab')) { + return; + } + + // Handle OAuth/SSO callbacks (Google OAuth or OIDC SSO) + if (ctx.path.startsWith('/callback/')) { + const newSession = ctx.context.newSession; - if (!newSession?.user?.email) { - throw new Error('Unable to complete OAuth/SSO signup: Missing user email'); + if (!newSession?.user?.email) { + throw new Error('Unable to complete OAuth/SSO signup: Missing user email'); + } } - } - // Handle SSO callbacks - if (ctx.path.startsWith('/sso/callback/')) { - const newSession = ctx.context.newSession; + // Handle SSO callbacks + if (ctx.path.startsWith('/sso/callback/')) { + const newSession = ctx.context.newSession; - if (!newSession?.user?.email) { - throw new Error('Unable to complete SSO signup: Missing user email'); + if (!newSession?.user?.email) { + throw new Error('Unable to complete SSO signup: Missing user email'); + } } - } - // Email/password authentication is handled automatically by Better Auth + // Email/password authentication is handled automatically by Better Auth + + const newSession = ctx.context.newSession; + const email = newSession?.user?.email as string | undefined; - const newSession = ctx.context.newSession; - const email = newSession?.user?.email as string | undefined; + if (!email) { + return; + } - if (!email) { - return; - } + const createdUser = await prisma.user.findUnique({ where: { email } }); - const createdUser = await prisma.user.findUnique({ where: { email } }); + if (!createdUser) { + return; + } - if (!createdUser) { - return; - } + // Skip anonymous users — we only want to grant system admin to the first + // non-anonymous user. + if (createdUser.isAnonymous) { + return; + } - // Skip anonymous users — we only want to grant system admin to the first - // non-anonymous user. - if (createdUser.isAnonymous) { - return; - } + // Only grant admin access if the application is not set up yet + const isApplicationSetUp = await userService.isApplicationSetUp(); - await grantSystemAdminAccess(createdUser.id); + if (!isApplicationSetUp) { + await grantSystemAdminAccess(createdUser.id); + } - // Check for pending invites and auto-accept them - try { - const pendingInvite = await inviteService.getInviteByEmail(email); + // Check for pending invites and auto-accept them + try { + const pendingInvite = await inviteService.getInviteByEmail(email); - if (pendingInvite) { - await inviteService.acceptInvite(pendingInvite.id, createdUser.id); + if (pendingInvite) { + await inviteService.acceptInvite(pendingInvite.id, createdUser.id); + } + } catch { + // Don't fail the auth flow if invite acceptance fails } - } catch { - // Don't fail the auth flow if invite acceptance fails - } - }), - }, -}); + }), + }, + }); +} + +// Create and export the auth instance +export const auth = await createAuthConfig(); async function grantSystemAdminAccess(userId: string) { // Check and potentially grant first-user system admin access diff --git a/app/components/BodyWrapper.tsx b/app/components/BodyWrapper.tsx new file mode 100644 index 00000000..5b1b037a --- /dev/null +++ b/app/components/BodyWrapper.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useEffect } from 'react'; + +export function BodyWrapper({ children }: { children: React.ReactNode }) { + useEffect(() => { + // Handle browser extension modifications that might cause hydration issues + const body = document.body; + + // Remove any attributes that might be added by browser extensions + // that could cause hydration mismatches + const attributesToRemove = ['data-ryu-obtrusive-scrollbars', 'data-ryu-scrollbars', 'data-ryu-scrollbars-visible']; + + attributesToRemove.forEach((attr) => { + if (body.hasAttribute(attr)) { + body.removeAttribute(attr); + } + }); + }, []); + + return <>{children}; +} diff --git a/app/components/DataLoader.tsx b/app/components/DataLoader.tsx index d478d1b1..1c0de56c 100644 --- a/app/components/DataLoader.tsx +++ b/app/components/DataLoader.tsx @@ -10,7 +10,7 @@ import type { PluginAccessMap } from '~/lib/plugins/types'; import { useUserStore } from '~/lib/stores/user'; import type { EnvironmentVariableWithDetails } from '~/lib/stores/environmentVariables'; import { useEnvironmentVariablesStore } from '~/lib/stores/environmentVariables'; -import { DATA_SOURCE_CONNECTION_ROUTE, TELEMETRY_CONSENT_ROUTE } from '~/lib/constants/routes'; +import { DATA_SOURCE_CONNECTION_ROUTE, TELEMETRY_CONSENT_ROUTE, ONBOARDING_ROUTE } from '~/lib/constants/routes'; import { initializeClientTelemetry } from '~/lib/telemetry/telemetry-client'; import type { UserProfile } from '~/lib/services/userService'; import { useAuthProvidersPlugin } from '~/lib/hooks/plugins/useAuthProvidersPlugin'; @@ -191,12 +191,14 @@ export function DataLoader({ children, rootData }: DataLoaderProps) { } // Handle user onboarding flow with telemetry and data sources - if (currentUser) { + // Only show telemetry consent and data source redirects for signed-in users + if (currentUser && session?.user) { // Redirect to telemetry consent screen if user hasn't answered yet + // Skip this check during admin onboarding to avoid interrupting the flow if (currentUser.telemetryEnabled === null) { const currentPath = window.location.pathname; - if (currentPath !== TELEMETRY_CONSENT_ROUTE) { + if (currentPath !== TELEMETRY_CONSENT_ROUTE && !currentPath.startsWith(ONBOARDING_ROUTE)) { router.push(TELEMETRY_CONSENT_ROUTE); return; } @@ -208,10 +210,11 @@ export function DataLoader({ children, rootData }: DataLoaderProps) { } // Redirect to data source connection if no data sources exist + // Skip this check during admin onboarding to avoid interrupting the flow if (currentEnvironmentDataSources.length === 0) { const currentPath = window.location.pathname; - if (currentPath !== DATA_SOURCE_CONNECTION_ROUTE) { + if (currentPath !== DATA_SOURCE_CONNECTION_ROUTE && !currentPath.startsWith(ONBOARDING_ROUTE)) { router.push(DATA_SOURCE_CONNECTION_ROUTE); return; } diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index 05afddf3..eec5c6c0 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -284,11 +284,7 @@ export function HeaderActionButtons() { for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); - // Skip build and .netlify directories - if ( - entry.isDirectory() && - (entry.name === 'build' || entry.name === '.netlify' || entry.name === 'node_modules') - ) { + if (entry.isDirectory() && ['build', '.netlify', 'node_modules', '.next'].includes(entry.name)) { continue; } diff --git a/app/components/onboarding/AdminOnboarding.tsx b/app/components/onboarding/AdminOnboarding.tsx new file mode 100644 index 00000000..bab1920a --- /dev/null +++ b/app/components/onboarding/AdminOnboarding.tsx @@ -0,0 +1,1429 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Button } from '~/components/ui/Button'; +import { Input } from '~/components/ui/Input'; +import { Label } from '~/components/ui/Label'; +import { Card } from '~/components/ui/Card'; +import { Checkbox } from '~/components/ui/Checkbox'; +import { Alert, AlertDescription } from '~/components/ui/alert'; +import { CheckCircle, ArrowRight, ArrowLeft, Info, Shield, Eye, EyeOff, Lock } from 'lucide-react'; +import { onboardingApi, clearOnboardingStatusCache } from '~/lib/api/onboarding'; +import { signIn, useSession } from '~/auth/auth-client'; +// Remove server-side env import - we'll pass env vars as props +import type { + AuthMethod, + OnboardingRequest, + AuthStepRequest, + AuthConfigStepRequest, + // LlmConfig, + // DatasourceConfig, + // UsersConfig, +} from '~/types/onboarding'; + +interface AdminOnboardingProps { + onComplete: () => void; + envVars?: { + // Non-sensitive configuration + defaultLlmProvider?: string; + defaultLlmModel?: string; + ollamaApiBaseUrl?: string; + disableTelemetry?: boolean; + + // Existence flags only + hasGoogleOAuth?: boolean; + hasOidcSso?: boolean; + hasOidcDomain?: boolean; + + // API Key existence flags + hasAnthropicApiKey?: boolean; + hasOpenaiApiKey?: boolean; + hasGoogleGenerativeAiApiKey?: boolean; + hasOpenRouterApiKey?: boolean; + hasGroqApiKey?: boolean; + hasMistralApiKey?: boolean; + hasCohereApiKey?: boolean; + hasPerplexityApiKey?: boolean; + hasTogetherApiKey?: boolean; + hasDeepseekApiKey?: boolean; + hasXaiApiKey?: boolean; + hasGithubApiKey?: boolean; + hasHyperbolicApiKey?: boolean; + hasHuggingfaceApiKey?: boolean; + hasOpenaiLikeApiKey?: boolean; + }; +} + +type OnboardingStep = 'welcome' | 'auth' | 'auth-config' | 'llm' | 'datasource' | 'users' | 'complete'; + +interface AuthMethodOption { + id: string; + name: string; + description: string; + icon: React.ReactNode; + recommended?: boolean; +} + +const authMethods: AuthMethodOption[] = [ + { + id: 'google', + name: 'Google OAuth', + description: 'Sign in with Google accounts', + icon: ( +
G
+ ), + recommended: true, + }, + { + id: 'sso', + name: 'Third-Party SSO', + description: 'Configure your own SSO provider', + icon: , + }, + { + id: 'password', + name: 'Username & Password', + description: 'Traditional email and password login', + icon: , + }, +]; + +export function AdminOnboarding({ onComplete, envVars }: AdminOnboardingProps) { + const searchParams = useSearchParams(); + const [currentStep, setCurrentStep] = useState('welcome'); + const [selectedAuthMethod, setSelectedAuthMethod] = useState(''); + const [adminData, setAdminData] = useState({ + name: '', + email: '', + password: '', + confirmPassword: '', + }); + const [ssoConfig, setSsoConfig] = useState({ + hostUrl: '', + clientId: '', + clientSecret: '', + scopes: 'openid email profile', + }); + const [googleConfig, setGoogleConfig] = useState({ + clientId: '', + clientSecret: '', + }); + const [isValidatingCredentials, setIsValidatingCredentials] = useState(false); + const [credentialsValid, setCredentialsValid] = useState(null); + const [credentialsValidationMessage, setCredentialsValidationMessage] = useState(''); + const [configSource, setConfigSource] = useState<'environment' | 'onboarding' | 'none' | null>(null); + // Hidden step state - keeping for future use + // const [llmConfig, setLlmConfig] = useState({ + // model: '', + // apiKey: '', + // baseUrl: '', + // }); + // const [datasourceConfig, setDatasourceConfig] = useState({ + // name: '', + // type: 'SQLITE', + // connectionString: '', + // properties: {}, + // }); + // const [usersConfig, setUsersConfig] = useState({ + // invitations: [], + // }); + // const [newInvitation, setNewInvitation] = useState({ email: '', role: 'MEMBER' }); + const [telemetryConsent, setTelemetryConsent] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isSigningInWithGoogle, setIsSigningInWithGoogle] = useState(false); + const [googleUser, setGoogleUser] = useState<{ name: string; email: string; picture?: string } | null>(null); + const [showSignInSuccess, setShowSignInSuccess] = useState(false); + const [redirectCountdown, setRedirectCountdown] = useState(0); + const [isApplicationSetUp, setIsApplicationSetUp] = useState(false); + const [prefilledFields, setPrefilledFields] = useState>(new Set()); + + // Monitor Better Auth session for Google OAuth + const { data: session, isPending, refetch } = useSession(); + + // Debug session changes + React.useEffect(() => { + console.log('Session changed:', { session, isPending }); + }, [session, isPending]); + + // Check if application is already set up + useEffect(() => { + const checkApplicationStatus = async () => { + try { + const status = await onboardingApi.checkStatus(); + setIsApplicationSetUp(status.isSetUp); + } catch (error) { + console.error('Error checking application status:', error); + } + }; + + checkApplicationStatus(); + }, []); + + // Prefill form with environment variables + useEffect(() => { + if (!envVars) { + return; + } + + const prefillFromEnv = () => { + const newPrefilledFields = new Set(); + + // Hidden step prefill logic - keeping for future use + // if (envVars.defaultLlmProvider && envVars.defaultLlmModel) { + // setLlmConfig((prev) => ({ + // ...prev, + // model: `${envVars.defaultLlmProvider}:${envVars.defaultLlmModel}`, + // baseUrl: envVars.defaultLlmProvider === 'Ollama' ? envVars.ollamaApiBaseUrl || '' : '', + // })); + // newPrefilledFields.add('llm-model'); + // if (envVars.defaultLlmProvider === 'Ollama') { + // newPrefilledFields.add('llm-baseUrl'); + // } + // } + + // Prefill Google OAuth configuration (only selection, not credentials) + if (envVars.hasGoogleOAuth) { + setSelectedAuthMethod('google'); + newPrefilledFields.add('auth-method'); + } + + // Prefill SSO configuration (only selection, not credentials) + if (envVars.hasOidcSso) { + setSelectedAuthMethod('sso'); + newPrefilledFields.add('auth-method'); + } + + // Prefill admin email if OIDC domain is available + if (envVars.hasOidcDomain) { + // We can't show the actual domain, but we can indicate it's configured + setAdminData((prev) => ({ + ...prev, + email: 'admin@yourdomain.com', // Placeholder + })); + newPrefilledFields.add('admin-email'); + } + + // Prefill telemetry consent based on environment + if (envVars.disableTelemetry) { + setTelemetryConsent(false); + newPrefilledFields.add('telemetry-consent'); + } + + setPrefilledFields(newPrefilledFields); + }; + + prefillFromEnv(); + }, [envVars]); + + // Handle URL parameters to restore onboarding state + useEffect(() => { + const step = searchParams.get('step') as OnboardingStep; + const authMethod = searchParams.get('authMethod') as AuthMethod; + const signedIn = searchParams.get('signedIn') === 'true'; + + if (step && ['auth', 'auth-config', 'complete'].includes(step)) { + setCurrentStep(step); + } + + if (authMethod && ['google', 'sso', 'password'].includes(authMethod)) { + setSelectedAuthMethod(authMethod); + } + + // If user just signed in, show success message + if (signedIn && session?.user) { + setGoogleUser({ + name: session.user.name || '', + email: session.user.email || '', + picture: session.user.image || undefined, + }); + setShowSignInSuccess(true); + + // Clear the URL parameters after processing + const url = new URL(window.location.href); + url.searchParams.delete('signedIn'); + window.history.replaceState({}, '', url.toString()); + } + }, [searchParams, session]); + + // Update Google user when session changes (user signs in) + React.useEffect(() => { + console.log('Session effect triggered:', { session, selectedAuthMethod }); + + if (session?.user && selectedAuthMethod === 'google') { + console.log('Setting Google user:', session.user); + setGoogleUser({ + name: session.user.name || '', + email: session.user.email || '', + picture: session.user.image || undefined, + }); + } + }, [session, selectedAuthMethod]); + + // Check Google OAuth configuration status when auth method changes + React.useEffect(() => { + if (selectedAuthMethod === 'google') { + checkConfigStatus(); + } + }, [selectedAuthMethod]); + + // Handle Google user data from URL parameters (after OAuth callback) + React.useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const googleUserParam = urlParams.get('googleUser'); + + if (googleUserParam && selectedAuthMethod === 'google') { + try { + const googleUserData = JSON.parse(googleUserParam); + setGoogleUser(googleUserData); + + // Clear the URL parameter + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('googleUser'); + window.history.replaceState({}, '', newUrl.toString()); + } catch (error) { + console.error('Failed to parse Google user data:', error); + } + } + }, [selectedAuthMethod]); + + const steps = [ + { id: 'auth', title: 'Setup Authorization', description: 'Choose authentication method' }, + { id: 'auth-config', title: 'Configure Auth', description: 'Set up authentication' }, + // Hidden steps - keeping for future use + // { id: 'llm', title: 'Connect an LLM', description: 'Configure your AI model' }, + // { id: 'datasource', title: 'Add Data Source', description: 'Connect your database' }, + // { id: 'users', title: 'Add Users (Optional)', description: 'Invite team members' }, + ]; + + const currentStepIndex = steps.findIndex((step) => step.id === currentStep); + + const handleNext = async () => { + setError(null); // Clear any existing errors + setIsLoading(true); + + try { + if (currentStep === 'welcome') { + setCurrentStep('auth'); + } else if (currentStep === 'auth') { + if (!selectedAuthMethod) { + setError('Please select an authentication method'); + return; + } + + // Save auth step progress + const authRequest: AuthStepRequest = { + authMethod: selectedAuthMethod, + }; + + const response = await onboardingApi.saveAuthStep(authRequest); + + if (!response.success) { + throw new Error(response.error); + } + + setCurrentStep('auth-config'); + } else if (currentStep === 'auth-config') { + // Validate required fields (not needed for Google OAuth as user info comes from OAuth) + if (selectedAuthMethod !== 'google') { + if (!adminData.name.trim()) { + setError('Name is required'); + return; + } + + if (!adminData.email.trim()) { + setError('Email is required'); + return; + } + } + + // Validate Google OAuth + if (selectedAuthMethod === 'google') { + // If not configured via environment variables, require manual configuration + if (configSource !== 'environment') { + if (!googleConfig.clientId.trim() || !googleConfig.clientSecret.trim()) { + setError('Google Client ID and Client Secret are required'); + return; + } + + if (credentialsValid !== true) { + setError('Please validate your Google OAuth credentials before proceeding'); + return; + } + } + + if (!googleUser) { + setError('Please sign in with Google to select your admin account'); + return; + } + } + + // Validate SSO config if using SSO + if (selectedAuthMethod === 'sso') { + if (!ssoConfig.hostUrl.trim() || !ssoConfig.clientId.trim() || !ssoConfig.clientSecret.trim()) { + setError('SSO configuration is incomplete. Please fill in all required fields.'); + return; + } + } + + // Validate password if using password auth + if (selectedAuthMethod === 'password') { + if (!adminData.password || !adminData.confirmPassword) { + setError('Password and confirmation are required for password authentication.'); + return; + } + + if (adminData.password !== adminData.confirmPassword) { + setError('Passwords do not match.'); + return; + } + + if (adminData.password.length < 8) { + setError('Password must be at least 8 characters long.'); + return; + } + } + + // Save auth-config step progress + const authConfigRequest: AuthConfigStepRequest = { + adminData: + selectedAuthMethod === 'google' && googleUser + ? { + name: googleUser.name, + email: googleUser.email, + } + : { + name: adminData.name.trim(), + email: adminData.email.trim(), + password: adminData.password || undefined, + confirmPassword: adminData.confirmPassword || undefined, + }, + ssoConfig: selectedAuthMethod === 'sso' ? ssoConfig : undefined, + googleOAuthConfig: + selectedAuthMethod === 'google' && configSource !== 'environment' ? googleConfig : undefined, + }; + + const response = await onboardingApi.saveAuthConfigStep(authConfigRequest); + + if (!response.success) { + throw new Error(response.error); + } + + // Skip to completion after auth-config (other steps are hidden) + // Clear the cache and check if the application is now set up + clearOnboardingStatusCache(); + + const status = await onboardingApi.checkStatus(); + setIsApplicationSetUp(status.isSetUp); + + // Refresh the session to get the latest user data + await refetch(); + + console.log('About to call handleCompleteSetup, current state:', { + isApplicationSetUp: status.isSetUp, + selectedAuthMethod, + configSource, + googleUser, + session: session?.user, + }); + + await handleCompleteSetup(); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleBack = () => { + setError(null); // Clear any existing errors + + if (currentStep === 'auth') { + setCurrentStep('welcome'); + } else if (currentStep === 'auth-config') { + setCurrentStep('auth'); + } + // Hidden steps removed from navigation + }; + + const checkConfigStatus = async () => { + try { + const response = await fetch('/api/auth/google-config-status'); + const data = (await response.json()) as { + success: boolean; + config: { source: 'environment' | 'onboarding' | 'none' }; + isAvailable: boolean; + credentials?: { clientId: string; clientSecret: string }; + }; + + if (data.success) { + setConfigSource(data.config.source); + + // If Google OAuth is already configured (either from environment or onboarding), + // we can skip the credential input and validation + if (data.isAvailable) { + setCredentialsValid(true); + + // If credentials are available, populate the form state + if (data.credentials) { + setGoogleConfig({ + clientId: data.credentials.clientId, + clientSecret: data.credentials.clientSecret, + }); + } + + if (data.config.source === 'environment') { + setCredentialsValidationMessage('Google OAuth is already configured via environment variables'); + } else if (data.config.source === 'onboarding') { + setCredentialsValidationMessage('Google OAuth is configured and ready to use'); + } + } else { + // Reset state when Google OAuth is not available + setCredentialsValid(null); + setCredentialsValidationMessage(''); + setGoogleConfig({ + clientId: '', + clientSecret: '', + }); + } + } + } catch (error) { + console.error('Failed to check config status:', error); + } + }; + + const handleValidateCredentials = async () => { + if (!googleConfig.clientId.trim() || !googleConfig.clientSecret.trim()) { + setError('Please enter both Client ID and Client Secret before validating'); + return; + } + + setIsValidatingCredentials(true); + setError(null); + setCredentialsValid(null); + setCredentialsValidationMessage(''); + + try { + const response = await fetch('/api/onboarding/validate-google-oauth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + clientId: googleConfig.clientId.trim(), + clientSecret: googleConfig.clientSecret.trim(), + }), + }); + + const data = (await response.json()) as { success: boolean; message?: string; error?: string }; + + if (data.success) { + setCredentialsValid(true); + setCredentialsValidationMessage(data.message || 'Credentials are valid'); + + // Save the validated credentials immediately so they're available for Google sign-in + await saveGoogleOAuthCredentials(); + } else { + setCredentialsValid(false); + setCredentialsValidationMessage(data.error || 'Validation failed'); + } + } catch { + setCredentialsValid(false); + setCredentialsValidationMessage('Failed to validate credentials. Please check your internet connection.'); + } finally { + setIsValidatingCredentials(false); + } + }; + + const saveGoogleOAuthCredentials = async () => { + try { + const response = await fetch('/api/onboarding/save-google-oauth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + clientId: googleConfig.clientId.trim(), + clientSecret: googleConfig.clientSecret.trim(), + }), + }); + + const data = (await response.json()) as { success: boolean; error?: string }; + + if (!data.success) { + console.error('Failed to save Google OAuth credentials:', data.error); + setError('Failed to save Google OAuth credentials. Please try again.'); + } else { + // Show a message that the application needs to be restarted + setCredentialsValidationMessage( + 'Credentials saved! Please restart the development server for Google OAuth to be available.', + ); + } + } catch (error) { + console.error('Error saving Google OAuth credentials:', error); + setError('Failed to save Google OAuth credentials. Please try again.'); + } + }; + + const handleGoogleSignIn = async () => { + setIsSigningInWithGoogle(true); + setError(null); + + try { + // Use Better Auth's built-in Google OAuth + const callbackURL = `/onboarding/callback?step=${currentStep}&authMethod=${selectedAuthMethod}`; + + // Use Better Auth's social provider + await signIn.social({ + provider: 'google', + callbackURL, // Redirect to our special callback that preserves state + newUserCallbackURL: callbackURL, // Ensure new users also preserve state + }); + } catch (error) { + setError(`Google sign-in failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + setIsSigningInWithGoogle(false); + } + }; + + const handleCompleteSetup = async () => { + console.log('handleCompleteSetup called with state:', { + selectedAuthMethod, + configSource, + googleUser, + credentialsValid, + session: session?.user, + }); + + setIsLoading(true); + setError(null); + + try { + // If application is already set up, just redirect to main page + if (isApplicationSetUp) { + onComplete(); + return; + } + + if (!selectedAuthMethod) { + throw new Error('No authentication method selected'); + } + + // Additional validation (not needed for Google OAuth as user info comes from OAuth) + if (selectedAuthMethod !== 'google') { + if (!adminData.name.trim()) { + throw new Error('Name is required'); + } + + if (!adminData.email.trim()) { + throw new Error('Email is required'); + } + } + + // Validate Google OAuth + if (selectedAuthMethod === 'google') { + console.log('Validating Google OAuth:', { configSource, googleUser, credentialsValid }); + + // If not configured via environment variables, require manual configuration + if (configSource === 'none') { + if (!googleConfig.clientId.trim() || !googleConfig.clientSecret.trim()) { + throw new Error('Google Client ID and Client Secret are required'); + } + + if (credentialsValid !== true) { + throw new Error('Please validate your Google OAuth credentials before proceeding'); + } + } + + if (!googleUser) { + console.log('Google user validation failed:', { googleUser, session: session?.user }); + throw new Error('Please sign in with Google to select your admin account'); + } + } + + // Validate SSO config if using SSO + if (selectedAuthMethod === 'sso') { + if (!ssoConfig.hostUrl.trim() || !ssoConfig.clientId.trim() || !ssoConfig.clientSecret.trim()) { + throw new Error('SSO configuration is incomplete. Please fill in all required fields.'); + } + } + + // Validate password if using password auth + if (selectedAuthMethod === 'password') { + if (!adminData.password || !adminData.confirmPassword) { + throw new Error('Password and confirmation are required for password authentication.'); + } + + if (adminData.password !== adminData.confirmPassword) { + throw new Error('Passwords do not match.'); + } + + if (adminData.password.length < 8) { + throw new Error('Password must be at least 8 characters long.'); + } + } + + const request: OnboardingRequest = { + authMethod: selectedAuthMethod, + adminData: + selectedAuthMethod === 'google' && googleUser + ? { + name: googleUser.name, + email: googleUser.email, + } + : { + name: adminData.name.trim(), + email: adminData.email.trim(), + password: adminData.password || undefined, + confirmPassword: adminData.confirmPassword || undefined, + }, + ssoConfig: selectedAuthMethod === 'sso' ? ssoConfig : undefined, + googleOAuthConfig: selectedAuthMethod === 'google' && configSource !== 'environment' ? googleConfig : undefined, + telemetryConsent, + }; + + console.log('Sending onboarding request:', request); + + const response = await onboardingApi.completeOnboarding(request); + + if (!response.success) { + throw new Error(response.error); + } + + setCurrentStep('complete'); + + // Show countdown and automatically redirect to main page after successful completion + setRedirectCountdown(3); + + const countdownInterval = setInterval(() => { + setRedirectCountdown((prev) => { + if (prev <= 1) { + clearInterval(countdownInterval); + onComplete(); + + return 0; + } + + return prev - 1; + }); + }, 1000); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleFinish = () => { + onComplete(); + }; + + // Helper component to show prefilled indicator + const PrefilledIndicator = ({ fieldId }: { fieldId: string }) => { + if (!prefilledFields.has(fieldId)) { + return null; + } + + return ( +
+ + Prefilled from environment +
+ ); + }; + + const renderWelcomeStep = () => ( +
+ {/* Main Content Container */} +
+ {/* Welcome Title */} +

+ Welcome to Liblab AI +

+ + {/* Description */} +

+ This short 2-step onboarding will help you set up authentication for your organization. +

+ + {/* Action Container */} +
+ {/* Get Started Button */} + + + {/* Telemetry Consent */} +
+ setTelemetryConsent(checked)} + className="w-5 h-5 bg-[#F7F7F8] rounded border-0" + /> + + +
+
+
+
+ ); + + const renderAuthConfigStep = () => ( +
+
+ {/* Header */} +
+

+ {selectedAuthMethod === 'google' && 'Set up Google OAuth'} + {selectedAuthMethod === 'sso' && 'Set up SSO'} + {selectedAuthMethod === 'password' && 'Set up Password Authentication'} +

+

+ {selectedAuthMethod === 'google' && 'Enter Google OAuth ID and secret found in your Google Console.'} + {selectedAuthMethod === 'sso' && 'Configure your SSO provider settings.'} + {selectedAuthMethod === 'password' && 'Set up username and password authentication.'} +

+
+ + {/* Configuration Form */} + + {selectedAuthMethod === 'google' && ( + <> +
+ +
+ +
+ + + +
+
+
+ + {/* Google OAuth Configuration */} +
+
+
+

Google OAuth Configuration

+ {configSource === 'environment' ? ( +
+

+ ✅ Google OAuth is already configured via environment variables +

+

You can proceed directly to sign in with Google.

+
+ ) : configSource === 'onboarding' ? ( +
+

✅ Google OAuth is configured and ready to use

+

You can sign in with Google below.

+
+ ) : ( +
+

Enter your Google OAuth credentials from the Google Cloud Console.

+

+ You can find these in your Google Cloud Console under APIs & Services > Credentials. +

+
+ )} +
+
+ + {configSource === 'none' && ( +
+ {/* Redirect URI Instructions */} +
+
+ + Important: Configure Redirect URI +
+

Add this redirect URI to your Google Cloud Console:

+ + {typeof window !== 'undefined' + ? `${window.location.origin}/api/auth/callback/google` + : 'https://your-domain.com/api/auth/callback/google'} + +

+ Go to Google Cloud Console → APIs & Services → Credentials → Your OAuth Client → Authorized + redirect URIs +

+
+ +
+
+ + { + setGoogleConfig((prev) => ({ ...prev, clientId: e.target.value })); + // Reset validation when user changes credentials + setCredentialsValid(null); + setCredentialsValidationMessage(''); + }} + placeholder="your-google-client-id.apps.googleusercontent.com" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF]" + /> +
+
+ +
+ { + setGoogleConfig((prev) => ({ ...prev, clientSecret: e.target.value })); + // Reset validation when user changes credentials + setCredentialsValid(null); + setCredentialsValidationMessage(''); + }} + placeholder="your-google-client-secret" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF] pr-10" + /> + +
+
+
+
+ )} + + {/* Credential Validation */} + {configSource === 'none' && ( +
+
+ +
+
+ )} + + {/* Validation Result */} + {credentialsValidationMessage && configSource === 'none' && ( +
+
+ {credentialsValid === true && } + {credentialsValid === false && } + {credentialsValid === null && } + + {credentialsValid === true ? 'Valid' : credentialsValid === false ? 'Invalid' : 'Validation'} + +
+

{credentialsValidationMessage}

+
+ )} +
+ + {/* Google Sign-In Section */} +
+
+
+

Test Google Sign-In

+ {configSource === 'environment' || configSource === 'onboarding' ? ( +

+ Click the button below to sign in with Google and create your admin account. +

+ ) : ( +

+ First validate your credentials above, then click the button below to test the Google sign-in. +

+ )} +

+ This will create your admin account using the Google account you sign in with. +

+
+
+ +
+ +
+ + {/* User Info Display */} + {googleUser && ( +
+
+ + Admin User Selected +
+
+
{googleUser.name}
+
{googleUser.email}
+
+
+ )} +
+ + )} + + {selectedAuthMethod === 'sso' && ( +
+
+ + setAdminData((prev) => ({ ...prev, name: e.target.value }))} + placeholder="John Doe" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF]" + /> +
+
+ + setAdminData((prev) => ({ ...prev, email: e.target.value }))} + placeholder="john@company.com" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF]" + /> +
+
+ + setSsoConfig((prev) => ({ ...prev, hostUrl: e.target.value }))} + placeholder="https://your-provider.com" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF]" + /> +
+
+ + setSsoConfig((prev) => ({ ...prev, clientId: e.target.value }))} + placeholder="your-client-id" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF]" + /> +
+
+ +
+ setSsoConfig((prev) => ({ ...prev, clientSecret: e.target.value }))} + placeholder="your-client-secret" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF] pr-10" + /> + +
+
+
+ + setSsoConfig((prev) => ({ ...prev, scopes: e.target.value }))} + placeholder="openid email profile" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF]" + /> +

Separated with a space

+
+
+ )} + + {selectedAuthMethod === 'password' && ( +
+
+ + setAdminData((prev) => ({ ...prev, name: e.target.value }))} + placeholder="John Doe" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF]" + /> +
+
+ + setAdminData((prev) => ({ ...prev, email: e.target.value }))} + placeholder="john@company.com" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF]" + /> +
+
+ +
+ setAdminData((prev) => ({ ...prev, password: e.target.value }))} + placeholder="Enter a secure password" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF] pr-10" + /> + +
+
+
+ +
+ setAdminData((prev) => ({ ...prev, confirmPassword: e.target.value }))} + placeholder="Confirm your password" + className="bg-[#0A0D1A] border-[#2A2D3A] text-[#F7F7F8] focus:border-[#3BCEFF] pr-10" + /> + +
+
+
+ )} +
+
+
+ ); + + const renderAuthStep = () => ( +
+
+ {/* Header */} +
+

Set Up Authorization Methods

+

+ Choose how your users sign in. Enable secure login methods such as Google or third-party SSO to simplify + access and protect your app. +

+
+ + {/* Auth Methods */} +
+ {authMethods.map((method) => ( + setSelectedAuthMethod(method.id as AuthMethod)} + > +
+
+ {selectedAuthMethod === method.id &&
} +
+ {method.icon} +
+
+

{method.name}

+ {method.recommended && ( + Recommended + )} +
+

{method.description}

+
+
+ + ))} +
+ + {/* Prefilled indicator for auth method */} + +
+
+ ); + + // Hidden steps - keeping for future use + // const renderLLMStep = () => { ... } + // const renderDataSourceStep = () => { ... } + // const renderUsersStep = () => { ... } + + const renderCompleteStep = () => ( +
+
+ {/* Success Icon */} +
+ +
+ + {/* Success Message */} +
+

+ Setup Complete! +

+

+ Your Liblab AI instance is now ready to use. You can start building internal apps and managing your team. +

+ {redirectCountdown > 0 && ( +

+ Redirecting to main page in {redirectCountdown} second{redirectCountdown !== 1 ? 's' : ''}... +

+ )} +
+ + {/* Finish Button */} + +
+
+ ); + + const renderCurrentStep = () => { + switch (currentStep) { + case 'welcome': + return renderWelcomeStep(); + case 'auth': + return renderAuthStep(); + case 'auth-config': + return renderAuthConfigStep(); + case 'complete': + return renderCompleteStep(); + default: + return null; + } + }; + + return ( +
+ {/* Background Pattern */} +
+
+
+ + {/* Header - Only show on non-welcome steps */} + {currentStep !== 'welcome' && ( +
+ {/* Logo */} +
+ liblab{'{ai}'} +
+ + {/* Progress Steps - Centered */} +
+
+ {steps.map((step, index) => ( +
+
+
+ {index + 1} +
+
+
+ {step.title} +
+
+
+ {index < steps.length - 1 &&
} +
+ ))} +
+
+ + {/* Empty space for balance */} +
+
+ )} + + {/* Main Content */} +
+
+ {showSignInSuccess && ( + + + Successfully signed in with Google! You can now continue with the onboarding process. + + + )} + + {error && ( + + {error} + + )} + + {renderCurrentStep()} +
+
+ + {/* Footer Navigation */} + {currentStep !== 'welcome' && currentStep !== 'complete' && ( +
+ + +
+ +
+
+ )} +
+ ); +} diff --git a/app/components/ui/Checkbox.tsx b/app/components/ui/Checkbox.tsx new file mode 100644 index 00000000..a2554bab --- /dev/null +++ b/app/components/ui/Checkbox.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Check } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; + +interface CheckboxProps { + id?: string; + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + disabled?: boolean; + className?: string; +} + +export function Checkbox({ id, checked = false, onCheckedChange, disabled = false, className }: CheckboxProps) { + return ( + + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 2b142403..3686372e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import './styles/index.scss'; import type { ReactNode } from 'react'; import { ClientProviders } from './components/ClientProviders'; +import { BodyWrapper } from './components/BodyWrapper'; import './globals.css'; import { getEnvironmentDataSources } from '~/lib/services/datasourceService'; import { userService } from '~/lib/services/userService'; @@ -31,6 +32,9 @@ export const metadata = { async function getRootData() { try { + // Check if application is set up first + const isApplicationSetUp = await userService.isApplicationSetUp(); + // Get session from headers const headersList = await headers(); const session = await auth.api.getSession({ @@ -79,6 +83,7 @@ async function getRootData() { deploymentProviders, pluginAccess, dataSourceTypes, + isApplicationSetUp, }; } catch (error) { console.error('Error loading root data:', error); @@ -90,6 +95,7 @@ async function getRootData() { deploymentProviders: [], pluginAccess: FREE_PLUGIN_ACCESS, dataSourceTypes: [], + isApplicationSetUp: false, }; } } @@ -113,8 +119,10 @@ export default async function RootLayout({ children }: { children: ReactNode })