diff --git a/.github/workflows/deploy-onboarding-status-web.yml b/.github/workflows/deploy-onboarding-status-web.yml index 8bdb786..877c1df 100644 --- a/.github/workflows/deploy-onboarding-status-web.yml +++ b/.github/workflows/deploy-onboarding-status-web.yml @@ -87,6 +87,11 @@ jobs: -e GCP_PROJECT_ID=test \ -e FIRESTORE_DATABASE_ID=test \ -e GITHUB_TOKEN=test \ + -e NEXT_PUBLIC_GOOGLE_CLIENT_ID=test-client-id \ + -e GOOGLE_CLIENT_SECRET=test-secret \ + -e SESSION_SECRET=test-session-secret-32chars-min \ + -e NEXT_PUBLIC_APP_URL=http://localhost:8080 \ + -e ALLOWED_DOMAINS=vectorinstitute.ai \ onboarding-status-web:${{ github.sha }} # Wait for container to start and verify it stays running @@ -232,7 +237,7 @@ jobs: --min-instances=0 \ --concurrency=80 \ --port=8080 \ - --set-env-vars="GCP_PROJECT_ID=${{ env.PROJECT_ID }},FIRESTORE_DATABASE_ID=${{ env.FIRESTORE_DATABASE_ID }},GITHUB_TOKEN=${{ secrets.GH_ORG_TOKEN }},DEPLOYMENT_SHA=${{ github.sha }}" \ + --set-env-vars="GCP_PROJECT_ID=${{ env.PROJECT_ID }},FIRESTORE_DATABASE_ID=${{ env.FIRESTORE_DATABASE_ID }},GITHUB_TOKEN=${{ secrets.GH_ORG_TOKEN }},DEPLOYMENT_SHA=${{ github.sha }},NEXT_PUBLIC_GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }},GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }},SESSION_SECRET=${{ secrets.SESSION_SECRET }},NEXT_PUBLIC_APP_URL=${{ secrets.APP_URL }},REDIRECT_URI=${{ secrets.REDIRECT_URI }},ALLOWED_DOMAINS=vectorinstitute.ai" \ --update-labels="deployed-by=github-actions,commit=${{ github.sha }}" \ --update-annotations="git-commit=${{ github.sha }},git-ref=${{ github.ref }},deployed-at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --quiet diff --git a/docs/onboarding-status-web-auth.md b/docs/onboarding-status-web-auth.md new file mode 100644 index 0000000..65e8db6 --- /dev/null +++ b/docs/onboarding-status-web-auth.md @@ -0,0 +1,99 @@ +# Onboarding Status Web Authentication + +## Overview + +The Onboarding Status Web dashboard uses Google OAuth 2.0 with server-side sessions for authentication. Only @vectorinstitute.ai email addresses can access the dashboard. + +## Architecture + +- **Library**: `@vector-institute/aieng-auth-core` +- **Session Management**: `iron-session` with encrypted HTTP-only cookies +- **Security**: PKCE flow, domain restriction, encrypted sessions +- **Path**: All routes under `/onboarding` base path + +## Authentication Flow + +1. User visits `/onboarding` → redirected to `/onboarding/login` if not authenticated +2. Click "Sign in with Google" → `/onboarding/api/auth/login` +3. Google OAuth flow with PKCE +4. Callback to `/onboarding/api/auth/callback` +5. Session created, user redirected to dashboard + +## Files + +### Configuration +- `lib/auth-config.ts` - OAuth config +- `lib/session.ts` - Session management + +### API Routes +- `app/api/auth/login/route.ts` - Initiate OAuth +- `app/api/auth/callback/route.ts` - Handle callback +- `app/api/auth/logout/route.ts` - Destroy session +- `app/api/auth/session/route.ts` - Get session info + +### Pages +- `app/page.tsx` - Protected dashboard +- `app/login/page.tsx` - Login page +- `app/dashboard-content.tsx` - Dashboard UI + +## Environment Variables + +```bash +# OAuth +NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=GOCSPX-your-secret +SESSION_SECRET=generate-with-openssl-rand-base64-32 + +# URLs +NEXT_PUBLIC_APP_URL=http://localhost:3000 +REDIRECT_URI=http://localhost:3000/onboarding/api/auth/callback + +# Domain restriction +ALLOWED_DOMAINS=vectorinstitute.ai +``` + +## Local Development + +1. Copy OAuth credentials from `aieng-template-auth/apps/demo-nextjs/.env` +2. Update redirect URI in `.env`: `http://localhost:3000/onboarding/api/auth/callback` +3. Run `npm run dev` +4. Visit `http://localhost:3000/onboarding` +5. Sign in with @vectorinstitute.ai account + +## Production Deployment + +### Required GitHub Secrets +- `GOOGLE_CLIENT_ID` - Shared Vector OAuth client ID +- `GOOGLE_CLIENT_SECRET` - OAuth client secret +- `SESSION_SECRET` - Generated with `openssl rand -base64 32` +- `APP_URL` - Production URL (e.g., `https://your-service.run.app`) +- `REDIRECT_URI` - Production callback URL (e.g., `https://your-service.run.app/onboarding/api/auth/callback`) + +### Setup Steps +1. Get shared OAuth client ID from admin +2. Ask admin to add production redirect URI to Google OAuth client +3. Set GitHub secrets in repository settings +4. Deploy via GitHub Actions workflow + +## Troubleshooting + +**"Invalid redirect_uri"** +- Verify redirect URI registered in Google Cloud Console +- Check `REDIRECT_URI` matches registered value + +**"Unauthorized domain"** +- User must have @vectorinstitute.ai email +- Check `ALLOWED_DOMAINS` environment variable + +**Session issues** +- Verify `SESSION_SECRET` is at least 32 characters +- Clear browser cookies + +## Dependencies + +```json +{ + "@vector-institute/aieng-auth-core": "^0.1.x", + "iron-session": "^8.0.1" +} +``` diff --git a/mkdocs.yml b/mkdocs.yml index 1957e06..5d790a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Overview: developer_guide.md - Infrastructure: - Onboarding Status Web - Load Balancer Setup: onboarding-status-web-load-balancer-setup.md + - Onboarding Status Web - Authentication: onboarding-status-web-auth.md plugins: - search - mkdocstrings: diff --git a/services/onboarding-status-web/.env.example b/services/onboarding-status-web/.env.example new file mode 100644 index 0000000..ee1482a --- /dev/null +++ b/services/onboarding-status-web/.env.example @@ -0,0 +1,20 @@ +# Google Cloud Configuration +GCP_PROJECT_ID=coderd +FIRESTORE_DATABASE_ID=onboarding + +# GitHub Configuration +GITHUB_TOKEN=your-github-token-here + +# Google OAuth Configuration +NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-client-id-here.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret-here + +# Session Configuration (generate with: openssl rand -base64 32) +SESSION_SECRET=generate-a-random-32-character-string-here + +# App URLs +NEXT_PUBLIC_APP_URL=http://localhost:3000 +REDIRECT_URI=http://localhost:3000/onboarding/api/auth/callback + +# Optional: Restrict access to specific email domains (comma-separated) +ALLOWED_DOMAINS=vectorinstitute.ai diff --git a/services/onboarding-status-web/README.md b/services/onboarding-status-web/README.md index 7f80dd0..f2ee21c 100644 --- a/services/onboarding-status-web/README.md +++ b/services/onboarding-status-web/README.md @@ -4,6 +4,7 @@ A modern, real-time web dashboard built with Next.js to display participant onbo ## Features +- **Authentication**: Secure Google OAuth SSO with domain restriction (@vectorinstitute.ai) - **Real-time Status Tracking**: Displays live participant onboarding status fetched from Firestore - **Clean, Polished UI**: Modern, responsive design with dark mode support - **Summary Statistics**: Shows total participants, onboarded count, completion percentage @@ -53,18 +54,24 @@ services/onboarding-status-web/ npm install ``` -2. Set environment variables: +2. Set up environment variables: ```bash - export GCP_PROJECT_ID=coderd - export FIRESTORE_DATABASE_ID=onboarding + cp .env.example .env ``` + Then edit `.env` and fill in the required values: + - Get Google OAuth credentials from [Google Cloud Console](https://console.cloud.google.com/apis/credentials) + - Generate a session secret: `openssl rand -base64 32` + - Set your GitHub token with appropriate permissions + 3. Run the development server: ```bash npm run dev ``` -4. Open [http://localhost:3000](http://localhost:3000) in your browser +4. Open [http://localhost:3000/onboarding](http://localhost:3000/onboarding) in your browser + +5. Sign in with a Vector Institute Google account (@vectorinstitute.ai) ## Deployment @@ -120,15 +127,34 @@ cd /path/to/aieng-platform ## Environment Variables +### Required - `GCP_PROJECT_ID`: Google Cloud Project ID (default: `coderd`) - `FIRESTORE_DATABASE_ID`: Firestore database ID (default: `onboarding`) +- `GITHUB_TOKEN`: GitHub personal access token for API access +- `NEXT_PUBLIC_GOOGLE_CLIENT_ID`: Google OAuth client ID +- `GOOGLE_CLIENT_SECRET`: Google OAuth client secret +- `SESSION_SECRET`: Secret for encrypting session cookies (generate with: `openssl rand -base64 32`) + +### Optional +- `NEXT_PUBLIC_APP_URL`: Full application URL (default: `http://localhost:3000`) +- `REDIRECT_URI`: OAuth callback URL (default: `${NEXT_PUBLIC_APP_URL}/onboarding/api/auth/callback`) +- `ALLOWED_DOMAINS`: Comma-separated list of allowed email domains (default: `vectorinstitute.ai`) - `PORT`: Port to run the server on (default: `8080`) ## API Endpoints +### Authentication Endpoints + +- `GET /api/auth/login` - Initiates Google OAuth flow +- `GET /api/auth/callback` - Handles OAuth callback and creates session +- `POST /api/auth/logout` - Destroys user session +- `GET /api/auth/session` - Returns current session information + +### Data Endpoints + ### GET /api/participants -Returns participant onboarding status and summary statistics. +Returns participant onboarding status and summary statistics. Requires authentication. **Response:** ```json @@ -174,10 +200,12 @@ Returns participant onboarding status and summary statistics. ## Security -- Uses Google Cloud service account authentication for Firestore access -- Runs as non-root user in Docker container -- Follows Cloud Run security best practices -- CORS configured for API routes +- **OAuth 2.0 Authentication**: Secure Google OAuth with PKCE flow +- **Session Management**: HTTP-only cookies with encrypted sessions using iron-session +- **Domain Restriction**: Only @vectorinstitute.ai email addresses can access +- **Firestore Access**: Service account authentication for database access +- **Container Security**: Runs as non-root user in Docker +- **CORS Configuration**: Properly configured API routes ## Performance @@ -206,6 +234,15 @@ If the Docker build fails: 2. Check that the `public` directory exists 3. Verify Node.js version compatibility +### Authentication Issues + +If you can't sign in: +1. Verify Google OAuth credentials are correct +2. Ensure redirect URI is registered in Google Cloud Console +3. Check that your email domain (@vectorinstitute.ai) is in ALLOWED_DOMAINS +4. Verify SESSION_SECRET is at least 32 characters +5. Check browser console for errors + ### Firestore Connection Issues If the dashboard can't fetch data: diff --git a/services/onboarding-status-web/app/api/auth/callback/route.ts b/services/onboarding-status-web/app/api/auth/callback/route.ts new file mode 100644 index 0000000..a4baf3e --- /dev/null +++ b/services/onboarding-status-web/app/api/auth/callback/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { GoogleOAuthClient } from '@vector-institute/aieng-auth-core'; +import { authConfig } from '@/lib/auth-config'; +import { createSession } from '@/lib/session'; +import { cookies } from 'next/headers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const error = searchParams.get('error'); + + if (error) { + return NextResponse.redirect(new URL(`/onboarding?error=${encodeURIComponent(error)}`, request.url)); + } + + if (!code || !state) { + return NextResponse.redirect(new URL('/onboarding?error=invalid_callback', request.url)); + } + + // Verify state + const cookieStore = await cookies(); + const storedState = cookieStore.get('oauth_state')?.value; + if (state !== storedState) { + return NextResponse.redirect(new URL('/onboarding?error=invalid_state', request.url)); + } + + // Get PKCE verifier + const verifier = cookieStore.get('pkce_verifier')?.value; + if (!verifier) { + return NextResponse.redirect(new URL('/onboarding?error=missing_verifier', request.url)); + } + + // Exchange code for tokens + const client = new GoogleOAuthClient(authConfig); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: authConfig.redirectUri, + client_id: authConfig.clientId, + client_secret: authConfig.clientSecret, + code_verifier: verifier, + }); + + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error('Token exchange failed:', errorData); + return NextResponse.redirect(new URL('/onboarding?error=token_exchange_failed', request.url)); + } + + const data = await response.json(); + const tokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + tokenType: data.token_type || 'Bearer', + expiresIn: data.expires_in, + scope: data.scope, + }; + + // Get user info + const user = await client.getUserInfo(tokens.accessToken); + + // Validate domain if configured + if (authConfig.allowedDomains && authConfig.allowedDomains.length > 0) { + const domain = user.email?.split('@')[1]; + if (!domain || !authConfig.allowedDomains.includes(domain)) { + return NextResponse.redirect( + new URL(`/onboarding?error=unauthorized_domain&domain=${domain}`, request.url) + ); + } + } + + // Create session + await createSession(tokens, user); + + // Clean up temporary cookies + const redirectResponse = NextResponse.redirect(new URL('/onboarding', request.url)); + redirectResponse.cookies.delete('pkce_verifier'); + redirectResponse.cookies.delete('oauth_state'); + + return redirectResponse; + } catch (error) { + console.error('Callback error:', error); + return NextResponse.redirect(new URL('/onboarding?error=authentication_failed', request.url)); + } +} diff --git a/services/onboarding-status-web/app/api/auth/login/route.ts b/services/onboarding-status-web/app/api/auth/login/route.ts new file mode 100644 index 0000000..7cfa71a --- /dev/null +++ b/services/onboarding-status-web/app/api/auth/login/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import { authConfig } from '@/lib/auth-config'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + // Generate PKCE and store in session + const pkce = await import('@vector-institute/aieng-auth-core').then((m) => m.generatePKCE()); + + // Build authorization URL + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + authUrl.searchParams.set('client_id', authConfig.clientId); + authUrl.searchParams.set('redirect_uri', authConfig.redirectUri); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', 'openid profile email'); + authUrl.searchParams.set('code_challenge', pkce.challenge); + authUrl.searchParams.set('code_challenge_method', pkce.method); + authUrl.searchParams.set('access_type', 'offline'); + authUrl.searchParams.set('prompt', 'consent'); + + const state = crypto.randomUUID(); + authUrl.searchParams.set('state', state); + + // Store PKCE and state in cookies (temporary, for callback) + const response = NextResponse.redirect(authUrl.toString()); + response.cookies.set('pkce_verifier', pkce.verifier, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 600, // 10 minutes + path: '/', + }); + response.cookies.set('oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 600, // 10 minutes + path: '/', + }); + + return response; + } catch (error) { + console.error('Login error:', error); + return NextResponse.json({ error: 'Failed to initiate login' }, { status: 500 }); + } +} diff --git a/services/onboarding-status-web/app/api/auth/logout/route.ts b/services/onboarding-status-web/app/api/auth/logout/route.ts new file mode 100644 index 0000000..22ead74 --- /dev/null +++ b/services/onboarding-status-web/app/api/auth/logout/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { destroySession } from '@/lib/session'; + +export const dynamic = 'force-dynamic'; + +export async function POST() { + try { + await destroySession(); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Logout error:', error); + return NextResponse.json({ error: 'Failed to logout' }, { status: 500 }); + } +} diff --git a/services/onboarding-status-web/app/api/auth/session/route.ts b/services/onboarding-status-web/app/api/auth/session/route.ts new file mode 100644 index 0000000..e54ddf6 --- /dev/null +++ b/services/onboarding-status-web/app/api/auth/session/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '@/lib/session'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const session = await getSession(); + + return NextResponse.json({ + isAuthenticated: session.isAuthenticated || false, + user: session.user || null, + }); + } catch (error) { + console.error('Session error:', error); + return NextResponse.json({ + isAuthenticated: false, + user: null, + }); + } +} diff --git a/services/onboarding-status-web/app/dashboard-content.tsx b/services/onboarding-status-web/app/dashboard-content.tsx new file mode 100644 index 0000000..958db0f --- /dev/null +++ b/services/onboarding-status-web/app/dashboard-content.tsx @@ -0,0 +1,499 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { User } from '@vector-institute/aieng-auth-core'; + +interface Participant { + github_handle: string; + team_name: string; + onboarded: boolean; + onboarded_at?: string; + first_name?: string; + last_name?: string; + github_status?: 'member' | 'pending' | 'not_invited'; +} + +interface Summary { + total: number; + onboarded: number; + notOnboarded: number; + percentage: number; +} + +interface ApiResponse { + participants: Participant[]; + summary: Summary; +} + +interface DashboardContentProps { + user: User | null; +} + +export default function DashboardContent({ user }: DashboardContentProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [statusFilter, setStatusFilter] = useState<'all' | 'onboarded' | 'not_onboarded'>('all'); + const [roleFilter, setRoleFilter] = useState<'participants' | 'facilitators'>('participants'); + + const handleLogout = async () => { + try { + await fetch('/onboarding/api/auth/logout', { method: 'POST' }); + window.location.href = '/onboarding/login'; + } catch (error) { + console.error('Logout failed:', error); + } + }; + + const fetchData = useCallback(async () => { + try { + setError(null); + const response = await fetch(`/onboarding/api/participants?role=${roleFilter}`, { + cache: 'no-store' + }); + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + + const result = await response.json(); + + // Fetch GitHub status for all participants + try { + const github_handles = result.participants.map((p: Participant) => p.github_handle); + const statusResponse = await fetch('/onboarding/api/github-status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ github_handles }), + cache: 'no-store' + }); + + if (statusResponse.ok) { + const statusData = await statusResponse.json(); + const statusMap = new Map( + statusData.statuses.map((s: { github_handle: string; status: string }) => [ + s.github_handle, + s.status + ]) + ); + + // Merge GitHub status with participant data + result.participants = result.participants.map((p: Participant) => ({ + ...p, + github_status: statusMap.get(p.github_handle) || 'not_invited' + })); + } + } catch (statusErr) { + console.warn('Failed to fetch GitHub status:', statusErr); + // Continue without GitHub status if it fails + } + + setData(result); + setLastUpdated(new Date()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch data'); + console.error('Error fetching participants:', err); + } finally { + setLoading(false); + } + }, [roleFilter]); + + useEffect(() => { + fetchData(); + + // Auto-refresh every 30 seconds + const interval = setInterval(fetchData, 30000); + + return () => clearInterval(interval); + }, [fetchData]); + + if (loading) { + return ( +
+
+
+

Loading participant data...

+
+
+ ); + } + + if (error) { + return ( +
+
+
⚠️
+

Error

+

{error}

+ +
+
+ ); + } + + if (!data) { + return null; + } + + const { participants, summary } = data; + + // Filter participants based on status + const filteredParticipants = participants.filter((participant) => { + if (statusFilter === 'onboarded') return participant.onboarded; + if (statusFilter === 'not_onboarded') return !participant.onboarded; + return true; // 'all' + }); + + // Helper function to render GitHub status badge + const renderGitHubStatus = (status?: 'member' | 'pending' | 'not_invited') => { + if (!status) { + return ( + + + + + Unknown + + ); + } + + switch (status) { + case 'member': + return ( + + + + + Member + + ); + case 'pending': + return ( + + + + + Pending + + ); + case 'not_invited': + return ( + + + + + Not Invited + + ); + } + }; + + // CSV export function + const exportToCSV = () => { + const headers = ['#', 'Name', 'GitHub Handle', 'GitHub Status', 'Team Name', 'Status', 'Onboarded At']; + const rows = filteredParticipants.map((participant, index) => { + const name = participant.first_name && participant.last_name + ? `${participant.first_name} ${participant.last_name}` + : participant.first_name || participant.last_name || 'N/A'; + const status = participant.onboarded ? 'Onboarded' : 'Not Onboarded'; + const onboardedAt = participant.onboarded_at || 'N/A'; + const githubStatus = participant.github_status + ? participant.github_status === 'member' + ? 'Member' + : participant.github_status === 'pending' + ? 'Pending' + : 'Not Invited' + : 'Unknown'; + + return [ + index + 1, + name, + participant.github_handle, + githubStatus, + participant.team_name, + status, + onboardedAt + ]; + }); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', `participants_${statusFilter}_${new Date().toISOString().split('T')[0]}.csv`); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( +
+ {/* Vector Brand Header Accent */} +
+ +
+
+ {/* Header */} +
+
+
+

+ Onboarding Status +

+

+ Track technical onboarding progress in real-time +

+ {lastUpdated && ( +

+ Last updated: {lastUpdated.toLocaleTimeString()} +

+ )} +
+
+ {user && ( +
+

Signed in as

+

{user.email}

+
+ )} + +
+
+
+ + {/* Summary Cards */} +
+
+
+ Total Participants +
+
+ {summary.total} +
+
+ +
+
+ Onboarded +
+
+ {summary.onboarded} +
+
+ +
+
+ Not Onboarded +
+
+ {summary.notOnboarded} +
+
+ +
+
+ Completion Rate +
+
+ {summary.percentage}% +
+
+
+ + {/* Progress Bar */} +
+
+ + Overall Progress + + + {summary.onboarded} of {summary.total} + +
+
+
+
+
+ + {/* Filter and Export Controls */} +
+
+ +
+
+ + + +
+ +
+
+ +
+ +
+
+ + + +
+ +
+
+ +
+ +
+
+ + {/* Participants Table */} +
+
+ + + + + + + + + + + + + {filteredParticipants.map((participant, index) => ( + + + + + + + + + ))} + +
+ # + + Name + + GitHub Handle + + GitHub Invite + + Team Name + + Status +
+ {index + 1} + +
+ + {participant.first_name && participant.last_name + ? `${participant.first_name} ${participant.last_name}` + : participant.first_name || participant.last_name || 'N/A'} + +
+
+ + {participant.github_handle} + + + {renderGitHubStatus(participant.github_status)} + + + {participant.team_name} + + + {participant.onboarded ? ( + + + + + Onboarded + + ) : ( + + + + + Not Onboarded + + )} +
+
+
+
+
+
+ ); +} diff --git a/services/onboarding-status-web/app/login/page.tsx b/services/onboarding-status-web/app/login/page.tsx new file mode 100644 index 0000000..30cc3ba --- /dev/null +++ b/services/onboarding-status-web/app/login/page.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; + +export default function LoginPage() { + return ( +
+ {/* Vector Brand Header Accent */} +
+ +
+

+ Onboarding Status +

+

+ Sign in with your Vector Institute Google account to access the onboarding dashboard. +

+ + + + + +
+

+ Note: Only Vector Institute email accounts (@vectorinstitute.ai) are allowed to access this dashboard. +

+
+
+
+ ); +} diff --git a/services/onboarding-status-web/app/page.tsx b/services/onboarding-status-web/app/page.tsx index 43b04e0..3f14454 100644 --- a/services/onboarding-status-web/app/page.tsx +++ b/services/onboarding-status-web/app/page.tsx @@ -1,467 +1,15 @@ -'use client'; +import { getSession } from '@/lib/session'; +import { redirect } from 'next/navigation'; +import DashboardContent from './dashboard-content'; -import { useCallback, useEffect, useState } from 'react'; +export default async function Home() { + const session = await getSession(); -interface Participant { - github_handle: string; - team_name: string; - onboarded: boolean; - onboarded_at?: string; - first_name?: string; - last_name?: string; - github_status?: 'member' | 'pending' | 'not_invited'; -} - -interface Summary { - total: number; - onboarded: number; - notOnboarded: number; - percentage: number; -} - -interface ApiResponse { - participants: Participant[]; - summary: Summary; -} - -export default function Home() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [lastUpdated, setLastUpdated] = useState(null); - const [statusFilter, setStatusFilter] = useState<'all' | 'onboarded' | 'not_onboarded'>('all'); - const [roleFilter, setRoleFilter] = useState<'participants' | 'facilitators'>('participants'); - - const fetchData = useCallback(async () => { - try { - setError(null); - const response = await fetch(`/onboarding/api/participants?role=${roleFilter}`, { - cache: 'no-store' - }); - - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.statusText}`); - } - - const result = await response.json(); - - // Fetch GitHub status for all participants - try { - const github_handles = result.participants.map((p: Participant) => p.github_handle); - const statusResponse = await fetch('/onboarding/api/github-status', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ github_handles }), - cache: 'no-store' - }); - - if (statusResponse.ok) { - const statusData = await statusResponse.json(); - const statusMap = new Map( - statusData.statuses.map((s: { github_handle: string; status: string }) => [ - s.github_handle, - s.status - ]) - ); - - // Merge GitHub status with participant data - result.participants = result.participants.map((p: Participant) => ({ - ...p, - github_status: statusMap.get(p.github_handle) || 'not_invited' - })); - } - } catch (statusErr) { - console.warn('Failed to fetch GitHub status:', statusErr); - // Continue without GitHub status if it fails - } - - setData(result); - setLastUpdated(new Date()); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch data'); - console.error('Error fetching participants:', err); - } finally { - setLoading(false); - } - }, [roleFilter]); - - useEffect(() => { - fetchData(); - - // Auto-refresh every 30 seconds - const interval = setInterval(fetchData, 30000); - - return () => clearInterval(interval); - }, [fetchData]); - - if (loading) { - return ( -
-
-
-

Loading participant data...

-
-
- ); - } - - if (error) { - return ( -
-
-
⚠️
-

Error

-

{error}

- -
-
- ); - } - - if (!data) { - return null; + if (!session.isAuthenticated) { + redirect('/login'); } - const { participants, summary } = data; - - // Filter participants based on status - const filteredParticipants = participants.filter((participant) => { - if (statusFilter === 'onboarded') return participant.onboarded; - if (statusFilter === 'not_onboarded') return !participant.onboarded; - return true; // 'all' - }); - - // Helper function to render GitHub status badge - const renderGitHubStatus = (status?: 'member' | 'pending' | 'not_invited') => { - if (!status) { - return ( - - - - - Unknown - - ); - } - - switch (status) { - case 'member': - return ( - - - - - Member - - ); - case 'pending': - return ( - - - - - Pending - - ); - case 'not_invited': - return ( - - - - - Not Invited - - ); - } - }; - - // CSV export function - const exportToCSV = () => { - const headers = ['#', 'Name', 'GitHub Handle', 'GitHub Status', 'Team Name', 'Status', 'Onboarded At']; - const rows = filteredParticipants.map((participant, index) => { - const name = participant.first_name && participant.last_name - ? `${participant.first_name} ${participant.last_name}` - : participant.first_name || participant.last_name || 'N/A'; - const status = participant.onboarded ? 'Onboarded' : 'Not Onboarded'; - const onboardedAt = participant.onboarded_at || 'N/A'; - const githubStatus = participant.github_status - ? participant.github_status === 'member' - ? 'Member' - : participant.github_status === 'pending' - ? 'Pending' - : 'Not Invited' - : 'Unknown'; - - return [ - index + 1, - name, - participant.github_handle, - githubStatus, - participant.team_name, - status, - onboardedAt - ]; - }); - - const csvContent = [ - headers.join(','), - ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) - ].join('\n'); - - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement('a'); - const url = URL.createObjectURL(blob); - - link.setAttribute('href', url); - link.setAttribute('download', `participants_${statusFilter}_${new Date().toISOString().split('T')[0]}.csv`); - link.style.visibility = 'hidden'; - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - - return ( -
- {/* Vector Brand Header Accent */} -
- -
-
- {/* Header */} -
-

- Onboarding Status -

-

- Track technical onboarding progress in real-time -

- {lastUpdated && ( -

- Last updated: {lastUpdated.toLocaleTimeString()} -

- )} -
- - {/* Summary Cards */} -
-
-
- Total Participants -
-
- {summary.total} -
-
- -
-
- Onboarded -
-
- {summary.onboarded} -
-
- -
-
- Not Onboarded -
-
- {summary.notOnboarded} -
-
- -
-
- Completion Rate -
-
- {summary.percentage}% -
-
-
- - {/* Progress Bar */} -
-
- - Overall Progress - - - {summary.onboarded} of {summary.total} - -
-
-
-
-
- - {/* Filter and Export Controls */} -
-
- -
-
- - - -
- -
-
- -
- -
-
- - - -
- -
-
- -
- -
-
+ const user = session.user; - {/* Participants Table */} -
-
- - - - - - - - - - - - - {filteredParticipants.map((participant, index) => ( - - - - - - - - - ))} - -
- # - - Name - - GitHub Handle - - GitHub Invite - - Team Name - - Status -
- {index + 1} - -
- - {participant.first_name && participant.last_name - ? `${participant.first_name} ${participant.last_name}` - : participant.first_name || participant.last_name || 'N/A'} - -
-
- - {participant.github_handle} - - - {renderGitHubStatus(participant.github_status)} - - - {participant.team_name} - - - {participant.onboarded ? ( - - - - - Onboarded - - ) : ( - - - - - Not Onboarded - - )} -
-
-
-
-
-
- ); + return ; } diff --git a/services/onboarding-status-web/lib/auth-config.ts b/services/onboarding-status-web/lib/auth-config.ts new file mode 100644 index 0000000..f60b660 --- /dev/null +++ b/services/onboarding-status-web/lib/auth-config.ts @@ -0,0 +1,38 @@ +import type { AuthConfig } from '@vector-institute/aieng-auth-core'; + +// Allow builds in CI/build environments with placeholder values +// Only enforce required environment variables at actual runtime +const isBuild = process.env.NEXT_PHASE === 'phase-production-build'; +const isCI = process.env.CI === 'true'; + +const clientId = + process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || (isBuild || isCI ? 'build-placeholder' : ''); +const clientSecret = + process.env.GOOGLE_CLIENT_SECRET || (isBuild || isCI ? 'build-placeholder' : ''); +const sessionSecret = + process.env.SESSION_SECRET || (isBuild || isCI ? 'build-placeholder-32-chars-minimum!!' : ''); + +// Only throw errors in actual runtime (not during build) +if (!isBuild && !isCI) { + if (!clientId) { + throw new Error('NEXT_PUBLIC_GOOGLE_CLIENT_ID is required'); + } + if (!clientSecret) { + throw new Error('GOOGLE_CLIENT_SECRET is required'); + } + if (!sessionSecret) { + throw new Error('SESSION_SECRET is required'); + } +} + +// Handle base path for redirect URI +const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; +const basePath = '/onboarding'; + +export const authConfig: AuthConfig = { + clientId, + clientSecret, + redirectUri: process.env.REDIRECT_URI || `${appUrl}${basePath}/api/auth/callback`, + postLogoutRedirectUri: `${appUrl}${basePath}`, + allowedDomains: process.env.ALLOWED_DOMAINS?.split(','), +}; diff --git a/services/onboarding-status-web/lib/session.ts b/services/onboarding-status-web/lib/session.ts new file mode 100644 index 0000000..a0d4a0d --- /dev/null +++ b/services/onboarding-status-web/lib/session.ts @@ -0,0 +1,39 @@ +import { getIronSession, IronSession } from 'iron-session'; +import { cookies } from 'next/headers'; +import type { AuthTokens, User } from '@vector-institute/aieng-auth-core'; + +export interface SessionData { + tokens: AuthTokens | null; + user: User | null; + isAuthenticated: boolean; +} + +const sessionOptions = { + password: process.env.SESSION_SECRET!, + cookieName: 'aieng_onboarding_auth_session', + cookieOptions: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + sameSite: 'lax' as const, + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/onboarding', // Set path to match basePath + }, +}; + +export async function getSession(): Promise> { + const cookieStore = await cookies(); + return getIronSession(cookieStore, sessionOptions); +} + +export async function createSession(tokens: AuthTokens, user: User): Promise { + const session = await getSession(); + session.tokens = tokens; + session.user = user; + session.isAuthenticated = true; + await session.save(); +} + +export async function destroySession(): Promise { + const session = await getSession(); + session.destroy(); +} diff --git a/services/onboarding-status-web/package-lock.json b/services/onboarding-status-web/package-lock.json index 40b151b..9500811 100644 --- a/services/onboarding-status-web/package-lock.json +++ b/services/onboarding-status-web/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@google-cloud/firestore": "^7.10.0", + "@vector-institute/aieng-auth-core": "^0.1.2", + "iron-session": "^8.0.4", "next": "14.2.18", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -1310,6 +1312,12 @@ "win32" ] }, + "node_modules/@vector-institute/aieng-auth-core": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@vector-institute/aieng-auth-core/-/aieng-auth-core-0.1.2.tgz", + "integrity": "sha512-3lJexjast9vHVdoDLkYiQIpUKl2dvy11wu7HvXg+qgw1aUr1GsiNbYqj9PKddhBW9vjvuBz71sHIAqZtga+XMQ==", + "license": "Apache-2.0" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2079,6 +2087,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3750,6 +3767,30 @@ "node": ">= 0.4" } }, + "node_modules/iron-session": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", + "funding": [ + "https://github.com/sponsors/vvo", + "https://github.com/sponsors/brc-dd" + ], + "license": "MIT", + "dependencies": { + "cookie": "^0.7.2", + "iron-webcrypto": "^1.2.1", + "uncrypto": "^0.1.3" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6555,6 +6596,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/services/onboarding-status-web/package.json b/services/onboarding-status-web/package.json index 354fce0..14872c9 100644 --- a/services/onboarding-status-web/package.json +++ b/services/onboarding-status-web/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@google-cloud/firestore": "^7.10.0", + "@vector-institute/aieng-auth-core": "^0.1.2", + "iron-session": "^8.0.4", "next": "14.2.18", "react": "^18.3.1", "react-dom": "^18.3.1"