From 13d23e468d1bb7fd8c8865db38018779c9f539e6 Mon Sep 17 00:00:00 2001 From: Stevan Kapicic Date: Mon, 15 Sep 2025 17:39:30 +0200 Subject: [PATCH 1/4] Skip .next directory on deploy zip upload (#159) * Skip .next dir on publish * Refactor to array.includes instead of individual checks --- app/components/header/HeaderActionButtons.client.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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; } From c508d4b09d5fccaa705d4d26b3c856c13ea06da2 Mon Sep 17 00:00:00 2001 From: V Steele Date: Wed, 17 Sep 2025 15:04:29 -0500 Subject: [PATCH 2/4] Fix broken logic while handling license access (#161) --- app/lib/plugins/plugin-manager.ts | 41 +++++++++++-------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/app/lib/plugins/plugin-manager.ts b/app/lib/plugins/plugin-manager.ts index 050814b2..2576a253 100644 --- a/app/lib/plugins/plugin-manager.ts +++ b/app/lib/plugins/plugin-manager.ts @@ -123,6 +123,11 @@ class PluginManager { // Mock API call until we implement the backend private async _fetchPluginAccess(): Promise { const license = env.server.LICENSE_KEY; + + if (!license || license !== 'premium') { + return FREE_PLUGIN_ACCESS; + } + const { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, @@ -139,33 +144,15 @@ class PluginManager { // Check if OIDC SSO is configured const hasOIDCSSO = !!(OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_CLIENT_SECRET && OIDC_DOMAIN && OIDC_PROVIDER_ID); - // If license is free, only allow anonymous auth and configured providers - if (!license || license !== 'premium') { - console.log('📋 Using FREE_PLUGIN_ACCESS with dynamic provider support'); - return { - ...FREE_PLUGIN_ACCESS, - [PluginType.AUTH]: { - ...FREE_PLUGIN_ACCESS[PluginType.AUTH], - google: hasGoogleOAuth, - oidc: hasOIDCSSO, - }, - }; - } - - // If license is premium but neither Google OAuth nor OIDC SSO is configured, fall back to free access - if (!hasGoogleOAuth && !hasOIDCSSO) { - console.warn( - 'Premium license detected but neither Google OAuth nor OIDC SSO configured. Falling back to free access.', - ); - console.log('📋 Falling back to FREE_PLUGIN_ACCESS'); - - return FREE_PLUGIN_ACCESS; - } - - // Premium license with Google OAuth or OIDC SSO configured - console.log('📋 Using PREMIUM_PLUGIN_ACCESS'); - - return PREMIUM_PLUGIN_ACCESS; + return { + ...PREMIUM_PLUGIN_ACCESS, + [PluginType.AUTH]: { + ...PREMIUM_PLUGIN_ACCESS[PluginType.AUTH], + anonymous: !(hasGoogleOAuth || hasOIDCSSO), + google: hasGoogleOAuth, + oidc: hasOIDCSSO, + }, + }; } } From 3cf03c70914624167cbe3f3b7d6c73e55950346f Mon Sep 17 00:00:00 2001 From: V Steele Date: Wed, 17 Sep 2025 15:37:58 -0500 Subject: [PATCH 3/4] Add SSO documentation (#162) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 3 +- docs/configuration.md | 30 +++++ docs/getting-started.md | 1 + docs/getting-started/sso-setup.md | 49 ++++++++ docs/security-and-privacy.md | 11 ++ docs/sso-setup.md | 183 ++++++++++++++++++++++++++++++ 6 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 docs/getting-started/sso-setup.md create mode 100644 docs/sso-setup.md 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/docs/configuration.md b/docs/configuration.md index 683b685d..cb137be6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -54,6 +54,36 @@ STARTER= Starters live in `starters/`. Each starter must include a `.liblab` directory with: `prompt`, `technologies`, `examples`, and `ignore`. +## Single Sign-On (SSO) Configuration + +liblab.ai supports OpenID Connect (OIDC) SSO integration with popular identity providers like Okta, Auth0, Google Workspace, and Azure AD. + +### Quick Setup + +Add these environment variables to enable SSO: + +```bash +# OIDC SSO Configuration +OIDC_ISSUER=https://your-identity-provider.com/oauth2/default +OIDC_CLIENT_ID=your-client-id-here +OIDC_CLIENT_SECRET=your-client-secret-here +OIDC_DOMAIN=yourcompany.com +OIDC_PROVIDER_ID=your-provider-id +OIDC_FRIENDLY_NAME=Continue with Company SSO +``` + +### Detailed Setup Guide + +For step-by-step instructions on configuring SSO with your identity provider, see our [SSO Setup Guide](sso-setup.md). + +### Supported Providers + +- **Okta** - Enterprise identity management +- **Auth0** - Universal authentication platform +- **Google Workspace** - Google's enterprise solution +- **Azure AD** - Microsoft's identity platform +- **Any OIDC-compatible provider** + ## Custom User Management Override default behavior by implementing a plugin at `app/lib/plugins/user-management/custom-user-management.ts` that implements the `UserManagementPlugin` interface. You can use provided services like `userService` and `organizationService`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1de6e8b0..0cf24555 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,4 +2,5 @@ - [Welcome](getting-started/welcome.md) - [Create your first app](getting-started/create-your-first-app.md) +- [Setting up SSO](getting-started/sso-setup.md) - [Browser support](getting-started/browser-support.md) diff --git a/docs/getting-started/sso-setup.md b/docs/getting-started/sso-setup.md new file mode 100644 index 00000000..209042ee --- /dev/null +++ b/docs/getting-started/sso-setup.md @@ -0,0 +1,49 @@ +# Setting Up Single Sign-On (SSO) + +This guide will help you quickly set up SSO for your liblab.ai instance so your team can sign in using your organization's existing identity provider. + +## Quick Start + +### 1. Get Your Identity Provider Details + +You'll need these details from your identity provider (Okta, Auth0, Google Workspace, etc.): + +- **Issuer URL** - Your identity provider's endpoint +- **Client ID** - Application identifier +- **Client Secret** - Application secret key +- **Domain** - Your organization's email domain (e.g., `yourcompany.com`) + +### 2. Add Environment Variables + +Add these to your `.env` file: + +- `OIDC_ISSUER` - Your identity provider's issuer URL +- `OIDC_CLIENT_ID` - Client ID from your identity provider +- `OIDC_CLIENT_SECRET` - Client secret from your identity provider +- `OIDC_DOMAIN` - Your organization's email domain +- `OIDC_PROVIDER_ID` - Unique identifier for this SSO provider +- `OIDC_FRIENDLY_NAME` - Display name for the SSO button + +### 3. Restart Your Application + +- **If using Docker:** Run `docker-compose restart` +- **If running locally:** Run `pnpm run dev` + +### 4. Test SSO + +1. Open your liblab.ai application +2. Look for the SSO button on the login page +3. Click it and sign in with your work credentials + +## Need More Help? + +For detailed setup instructions with your specific identity provider, see our [complete SSO Setup Guide](../sso-setup.md). + +## What Happens Next? + +Once SSO is configured: + +- Your team can sign in with their work credentials +- New users are automatically added to your organization +- You can manage access through your identity provider +- All authentication policies from your identity provider apply diff --git a/docs/security-and-privacy.md b/docs/security-and-privacy.md index 14898bc4..f8361a13 100644 --- a/docs/security-and-privacy.md +++ b/docs/security-and-privacy.md @@ -32,6 +32,17 @@ Local Machine ← Encrypted Response ← Secure Tunnel ← Preview Dashboard - No data is stored on external servers - You maintain complete control over your data access +## Single Sign-On (SSO) Security + +For enterprise deployments, liblab.ai supports OpenID Connect (OIDC) SSO integration, allowing you to: + +- **Centralize authentication** through your existing identity provider (Okta, Auth0, Google Workspace, etc.) +- **Enforce organizational security policies** like multi-factor authentication and password requirements +- **Control access centrally** without managing separate user accounts +- **Audit user access** through your identity provider's logging system + +See our [SSO Setup Guide](sso-setup.md) for configuration instructions. + ## Telemetry ### Basic Telemetry diff --git a/docs/sso-setup.md b/docs/sso-setup.md new file mode 100644 index 00000000..de8387c0 --- /dev/null +++ b/docs/sso-setup.md @@ -0,0 +1,183 @@ +# Single Sign-On (SSO) Setup + +This guide will help you set up Single Sign-On (SSO) for your liblab.ai instance using OpenID Connect (OIDC). SSO allows your team members to sign in using your organization's existing identity provider (like Okta, Auth0, or Google Workspace) instead of creating separate accounts. + +## What is SSO? + +Single Sign-On (SSO) lets your team members use their existing work credentials to access liblab.ai. Instead of remembering another username and password, they can sign in with the same login they use for other company tools. + +**Benefits:** + +- ✅ No need to create new accounts for team members +- ✅ Centralized user management through your existing identity provider +- ✅ Enhanced security with your organization's authentication policies +- ✅ Automatic user provisioning and role assignment + +## Prerequisites + +Before setting up SSO, you'll need: + +1. **An OIDC-compatible identity provider** (such as Okta, Auth0, Google Workspace, or Azure AD) +2. **Administrator access** to your identity provider +3. **Access to your liblab.ai environment variables** (usually in a `.env` file) + +## Step 1: Configure Your Identity Provider + +### For Okta + +1. **Log into your Okta Admin Console** +2. **Create a new application:** + - Go to Applications → Applications + - Click "Create App Integration" + - Choose "OIDC - OpenID Connect" + - Select "Web Application" + - Click "Next" + +3. **Configure the application:** + - **App integration name:** `liblab.ai` (or your preferred name) + - **Grant type:** Check "Authorization Code" and "Refresh Token" + - **Sign-in redirect URIs:** `https://your-liblab-domain.com/api/auth/sso/callback/your-provider-id` + - **Sign-out redirect URIs:** `https://your-liblab-domain.com` + - **Controlled access:** Choose appropriate access settings for your organization + +4. **Get your configuration details:** + - Note down the **Client ID** and **Client Secret** from the General tab + - The **Issuer URL** is typically: `https://your-domain.okta.com/oauth2/default` + +### For Auth0 + +1. **Log into your Auth0 Dashboard** +2. **Create a new application:** + - Go to Applications → Applications + - Click "Create Application" + - Choose "Regular Web Applications" + - Click "Create" + +3. **Configure the application:** + - **Name:** `liblab.ai` (or your preferred name) + - **Allowed Callback URLs:** `https://your-liblab-domain.com/api/auth/sso/callback/your-provider-id` + - **Allowed Logout URLs:** `https://your-liblab-domain.com` + - **Token Endpoint Authentication Method:** `POST` + +4. **Get your configuration details:** + - Note down the **Client ID** and **Client Secret** from the Settings tab + - The **Issuer URL** is typically: `https://your-domain.auth0.com/` + +### For Google Workspace + +1. **Go to Google Cloud Console** +2. **Create a new project or select an existing one** +3. **Create OAuth 2.0 credentials:** + - Go to APIs & Services → Credentials + - Click "Create Credentials" → "OAuth client ID" + - Choose "Web application" + - Under **Authorized redirect URIs**, add `https://your-liblab-domain.com/api/auth/sso/callback/your-provider-id` +4. **Get your configuration details:** + - After creating the client ID, note down the **Client ID** and **Client Secret**. + - The **Issuer URL** is: `https://accounts.google.com` + +## Step 2: Configure liblab.ai + +Add the following environment variables to your `.env` file: + +```bash +# OIDC SSO Configuration +OIDC_ISSUER=https://your-identity-provider.com/oauth2/default +OIDC_CLIENT_ID=your-client-id-here +OIDC_CLIENT_SECRET=your-client-secret-here +OIDC_DOMAIN=yourcompany.com +OIDC_PROVIDER_ID=your-provider-id +OIDC_FRIENDLY_NAME=Continue with Company SSO +``` + +### Environment Variable Details + +| Variable | Description | Example | +| -------------------- | ----------------------------------------- | ----------------------------------------- | +| `OIDC_ISSUER` | Your identity provider's issuer URL | `https://company.okta.com/oauth2/default` | +| `OIDC_CLIENT_ID` | Client ID from your identity provider | `0oa1b2c3d4e5f6g7h8i9j0` | +| `OIDC_CLIENT_SECRET` | Client secret from your identity provider | `abc123def456ghi789` | +| `OIDC_DOMAIN` | Your organization's email domain | `yourcompany.com` | +| `OIDC_PROVIDER_ID` | Unique identifier for this SSO provider | `company-okta` | +| `OIDC_FRIENDLY_NAME` | Display name for the SSO button | `Continue with Company SSO` | + +## Step 3: Restart Your Application + +After adding the environment variables, restart your liblab.ai application: + +```bash +# If using Docker +docker-compose restart + +# If running locally +pnpm run dev +``` + +## Step 4: Test the SSO Setup + +1. **Open your liblab.ai application** in a web browser +2. **Go to the login page** +3. **Look for your SSO button** - it should display the friendly name you configured +4. **Click the SSO button** to test the authentication flow +5. **Sign in with your organization credentials** + +## Step 5: User Provisioning + +When users sign in through SSO for the first time, liblab.ai will automatically: + +- ✅ Create a new user account +- ✅ Assign them to your organization +- ✅ Set appropriate permissions based on their role +- ✅ Accept any pending invitations for their email address + +## Troubleshooting + +### Common Issues + +**"SSO button not appearing"** + +- Check that all required environment variables are set correctly +- Verify that your application has been restarted after adding the variables +- Check the application logs for any configuration errors + +**"Authentication fails"** + +- Verify that the redirect URI in your identity provider matches exactly: `https://your-domain.com/api/auth/sso/callback/your-provider-id` +- Check that the issuer URL is correct and accessible +- Ensure the client ID and secret are correct + +**"Users can't sign in"** + +- Verify that the domain in `OIDC_DOMAIN` matches your users' email domains +- Check that your identity provider is configured to allow the redirect URI +- Ensure users have access to the application in your identity provider + +### Getting Help + +If you encounter issues: + +1. **Check the application logs** for error messages +2. **Verify your identity provider configuration** matches the examples above +3. **Test with a single user** before rolling out to your entire team +4. **Contact support** if you need additional assistance + +## Security Considerations + +- **Keep your client secret secure** - never commit it to version control +- **Use HTTPS** for your liblab.ai instance in production +- **Regularly rotate your client secrets** according to your organization's security policies +- **Monitor SSO usage** through your identity provider's audit logs + +## Advanced Configuration + +For advanced users, you can customize the SSO configuration by modifying the authentication setup in your liblab.ai instance. See the [technical SSO documentation](sso.md) for more details. + +## Next Steps + +Once SSO is set up: + +1. **Invite your team members** - they can now sign in using their work credentials +2. **Set up role-based permissions** - configure what each team member can access +3. **Monitor usage** - track who's using the system and how + +Your team can now enjoy seamless access to liblab.ai using their existing work credentials! 🎉 From 9441c90a93e276eb3ad1c63f3b710f11494af282 Mon Sep 17 00:00:00 2001 From: Cameron Steele Date: Wed, 24 Sep 2025 17:18:04 -0500 Subject: [PATCH 4/4] Implement onboarding API and middleware enhancements - Introduced a comprehensive onboarding API with multiple endpoints for managing onboarding steps, including authentication, data sources, and user configurations. - Added middleware to check onboarding status and cache results to optimize API calls. - Implemented Google OAuth integration with validation and configuration management. - Enhanced user experience by redirecting users based on onboarding status. - Created new components for onboarding UI and improved data handling in the onboarding process. - Updated database schema to support onboarding progress tracking. This commit lays the groundwork for a streamlined onboarding experience, ensuring users can efficiently set up their applications. --- app/api/auth/google-config-status/route.ts | 46 + app/api/auth/google/callback/route.ts | 57 + app/api/onboarding/auth-config/route.ts | 129 ++ app/api/onboarding/auth/route.ts | 114 ++ app/api/onboarding/complete/route.ts | 210 +++ app/api/onboarding/datasource/route.ts | 114 ++ .../google-oauth/authorize/route.ts | 42 + .../onboarding/google-oauth/callback/route.ts | 148 ++ app/api/onboarding/google-user-info/route.ts | 95 ++ app/api/onboarding/llm/route.ts | 114 ++ app/api/onboarding/save-google-oauth/route.ts | 75 + app/api/onboarding/status/route.ts | 35 + app/api/onboarding/users/route.ts | 114 ++ .../onboarding/validate-google-oauth/route.ts | 120 ++ app/api/test-middleware/route.ts | 8 + app/auth/auth-config.ts | 230 +-- app/components/BodyWrapper.tsx | 22 + app/components/DataLoader.tsx | 11 +- app/components/onboarding/AdminOnboarding.tsx | 1429 +++++++++++++++++ app/components/ui/Checkbox.tsx | 33 + app/layout.tsx | 12 +- app/lib/api/onboarding.ts | 395 +++++ app/lib/constants/routes.ts | 1 + app/lib/services/googleAuthService.ts | 129 ++ app/lib/services/userService.ts | 18 + app/onboarding/OnboardingClient.tsx | 84 + app/onboarding/OnboardingWrapper.tsx | 37 + app/onboarding/callback/page.tsx | 55 + app/onboarding/page.tsx | 5 + app/page.tsx | 1 - app/types/onboarding.ts | 287 ++++ middleware.ts | 80 +- .../migration.sql | 21 + .../migration.sql | 2 + prisma/schema.prisma | 19 + 35 files changed, 4174 insertions(+), 118 deletions(-) create mode 100644 app/api/auth/google-config-status/route.ts create mode 100644 app/api/auth/google/callback/route.ts create mode 100644 app/api/onboarding/auth-config/route.ts create mode 100644 app/api/onboarding/auth/route.ts create mode 100644 app/api/onboarding/complete/route.ts create mode 100644 app/api/onboarding/datasource/route.ts create mode 100644 app/api/onboarding/google-oauth/authorize/route.ts create mode 100644 app/api/onboarding/google-oauth/callback/route.ts create mode 100644 app/api/onboarding/google-user-info/route.ts create mode 100644 app/api/onboarding/llm/route.ts create mode 100644 app/api/onboarding/save-google-oauth/route.ts create mode 100644 app/api/onboarding/status/route.ts create mode 100644 app/api/onboarding/users/route.ts create mode 100644 app/api/onboarding/validate-google-oauth/route.ts create mode 100644 app/api/test-middleware/route.ts create mode 100644 app/components/BodyWrapper.tsx create mode 100644 app/components/onboarding/AdminOnboarding.tsx create mode 100644 app/components/ui/Checkbox.tsx create mode 100644 app/lib/api/onboarding.ts create mode 100644 app/lib/services/googleAuthService.ts create mode 100644 app/onboarding/OnboardingClient.tsx create mode 100644 app/onboarding/OnboardingWrapper.tsx create mode 100644 app/onboarding/callback/page.tsx create mode 100644 app/onboarding/page.tsx create mode 100644 app/types/onboarding.ts create mode 100644 prisma/migrations/20250922161157_add_onboarding_progress/migration.sql create mode 100644 prisma/migrations/20250924171327_add_google_oauth_config_to_onboarding/migration.sql 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/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 })