From ae96015ce3d92c1a57a2a4898a740867834c340f Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 13:45:05 -0800 Subject: [PATCH 01/10] ACHOO-146: initial implementation of AuthZ --- .env.example | 11 ++ .github/copilot-instructions.md | 43 ++++++ .gitignore | 3 + README.md | 7 + app/api/acquia/applications/route.ts | 3 + app/api/acquia/views/route.ts | 43 +++--- app/api/acquia/visits/route.ts | 3 + app/applications/[uuid]/page.tsx | 59 ++++++-- docs/SAML.md | 136 +++++++++++++++++++ lib/api-auth.ts | 162 ++++++++++++++++++++++ lib/auth-utils.ts | 194 +++++++++++++++++++++++++++ middleware.ts | 93 ++++++++----- 12 files changed, 699 insertions(+), 58 deletions(-) create mode 100644 lib/api-auth.ts diff --git a/.env.example b/.env.example index adc275c..778ac54 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,14 @@ SAML_SP_CERT="-----BEGIN CERTIFICATE----- SAML_SP_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY-----" + +# Authorization Configuration +# Global access via eduPersonEntitlement (comma-separated list) +# Users with any of these entitlements get full access to all applications +CHURRO_GLOBAL_ENTITLEMENTS=stanford:staff + +# Per-application access via SUNet ID mappings +# Format: uuid1:uid1,uid2;uuid2:uid3,uid4 +# Each section (separated by ;) maps an application UUID to authorized SUNet IDs +# Example: CHURRO_APP_ACCESS=app-uuid-1:jdoe,jsmith;app-uuid-2:jdoe +CHURRO_APP_ACCESS= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 91b87e5..8d700e0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -141,6 +141,49 @@ const getAttr = (key: string): string | undefined => { - JWT tokens stored in HTTP-only cookies (not accessible to JavaScript) - Clock skew: 5 minutes (`acceptedClockSkewMs: 300000`) +### Authorization System + +**Two-Tier Access Control**: +1. **Global Access**: Users with specific `eduPersonEntitlement` values access everything +2. **Per-Application Access**: SUNet ID mappings grant access to specific applications + +**Environment Variables**: +- `CHURRO_GLOBAL_ENTITLEMENTS` - Comma-separated list (e.g., `uit:sws,stanford:faculty`) +- `CHURRO_APP_ACCESS` - UUID:uid mappings (e.g., `uuid1:jdoe,jsmith;uuid2:jdoe`) + +**Key Components** (`lib/auth-utils.ts`): +- `hasGlobalAccess(user)` - Check if user has global entitlement +- `hasApplicationAccess(user, uuid)` - Check specific app access +- `hasDashboardAccess(user)` - Check if can access dashboard (global OR any app) +- `parseAppAccessMappings()` - Parse environment variable mappings + +**Middleware Protection** (`middleware.ts`): +- `/` - Dashboard requires global access OR access to ≥1 application +- `/applications/[uuid]` - Requires global access OR specific application access +- Returns 403 with clear error messages for unauthorized access + +**API Protection** (`lib/api-auth.ts`): +```typescript +export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { + // Protected API logic with user context + return NextResponse.json({ data: 'protected' }); + })(request); +} +``` + +**Client-Side Handling**: +- Authorization errors (403) display user-friendly error pages +- Client components check API response status and handle authorization failures +- Provides contact information and "Return to Dashboard" links + +**Authorization Flow**: +1. User authenticates via Stanford SAML +2. Middleware checks global entitlements first +3. If no global access, checks per-application mappings +4. Routes/APIs enforce access before rendering/processing +5. Clear error messages for authorization failures + ### Component Patterns **Client Components** - Use `'use client'` directive when: diff --git a/.gitignore b/.gitignore index b92cb84..4a494f7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ next-env.d.ts # saml saml-sp.key saml-sp.crt + +# Cache directory, if present +.cache diff --git a/README.md b/README.md index 417a336..a357788 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,13 @@ Then open: | `SAML_SP_CERT` | Your SP certificate | Multi-line PEM format | | `SAML_SP_PRIVATE_KEY` | Your SP private key | Multi-line PEM format | +### Authorization Variables + +| Variable | Description | Example | +|----------|-------------|----------| +| `CHURRO_GLOBAL_ENTITLEMENTS` | eduPersonEntitlement values for global access | `uit:sws` or `uit:sws,other:entitlement` | +| `CHURRO_APP_ACCESS` | Per-application SUNet ID mappings | `uuid1:jdoe,jsmith;uuid2:jdoe` | + ### Optional Variables | Variable | Description | Default | diff --git a/app/api/acquia/applications/route.ts b/app/api/acquia/applications/route.ts index 463fc78..9eb97d5 100644 --- a/app/api/acquia/applications/route.ts +++ b/app/api/acquia/applications/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: any }) => { // console.log('🚀 Applications API Route called'); // Update the API service initialization with better error handling @@ -55,4 +57,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } \ No newline at end of file diff --git a/app/api/acquia/views/route.ts b/app/api/acquia/views/route.ts index 0975826..f6eeb84 100644 --- a/app/api/acquia/views/route.ts +++ b/app/api/acquia/views/route.ts @@ -1,27 +1,29 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const subscriptionUuid = searchParams.get('subscriptionUuid'); - const from = searchParams.get('from'); - const to = searchParams.get('to'); - const resolution = searchParams.get('resolution'); // Get granularity for daily data - /** - console.log('🚀 Views by Application API Route called with params:', { - subscriptionUuid, - from, - to, - resolution - }); - */ - if (!subscriptionUuid) { - console.error('❌ Missing required parameter: subscriptionUuid'); - return NextResponse.json( - { error: 'subscriptionUuid is required' }, - { status: 400 } - ); - } + return withApiAuthorization(async (request: NextRequest, context: { user: any }) => { + const searchParams = request.nextUrl.searchParams; + const subscriptionUuid = searchParams.get('subscriptionUuid'); + const from = searchParams.get('from'); + const to = searchParams.get('to'); + const resolution = searchParams.get('resolution'); // Get granularity for daily data + /** + console.log('🚀 Views by Application API Route called with params:', { + subscriptionUuid, + from, + to, + resolution + }); + */ + if (!subscriptionUuid) { + console.error('❌ Missing required parameter: subscriptionUuid'); + return NextResponse.json( + { error: 'subscriptionUuid is required' }, + { status: 400 } + ); + } if (!process.env.ACQUIA_API_KEY || !process.env.ACQUIA_API_SECRET) { console.error('❌ Missing required environment variables!'); @@ -82,4 +84,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } diff --git a/app/api/acquia/visits/route.ts b/app/api/acquia/visits/route.ts index 94104ae..56e3067 100644 --- a/app/api/acquia/visits/route.ts +++ b/app/api/acquia/visits/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: any }) => { const searchParams = request.nextUrl.searchParams; const subscriptionUuid = searchParams.get('subscriptionUuid'); const from = searchParams.get('from'); @@ -85,4 +87,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } diff --git a/app/applications/[uuid]/page.tsx b/app/applications/[uuid]/page.tsx index 339d86d..729cc95 100644 --- a/app/applications/[uuid]/page.tsx +++ b/app/applications/[uuid]/page.tsx @@ -41,6 +41,20 @@ export default function ApplicationDetailPage({ params }: any) { const [error, setError] = useState(null); const [dailyViews, setDailyViews] = useState([]); const [dailyVisits, setDailyVisits] = useState([]); + const [authError, setAuthError] = useState(null); + + // Check for authorization errors from API calls + const handleApiResponse = async (response: Response) => { + if (response.status === 403) { + const errorData = await response.json().catch(() => ({})); + setAuthError(errorData.error || 'Access denied. You do not have permission to view this application.'); + return null; + } + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + return response.json(); + }; // Fetch application name on mount or when subscriptionUuid changes useEffect(() => { @@ -48,10 +62,13 @@ export default function ApplicationDetailPage({ params }: any) { try { setLoadingStep('Fetching application info...'); const res = await fetch(`/api/acquia/applications?subscriptionUuid=${subscriptionUuid}`); - const apps = await res.json(); + const apps = await handleApiResponse(res); + if (!apps) return; // Authorization error handled by handleApiResponse + const app = Array.isArray(apps) ? apps.find((a: any) => a.uuid === typedParams.uuid) : null; setAppName(app ? app.name : ''); - } catch { + } catch (err) { + console.error('Error fetching app name:', err); setAppName(''); } finally { setLoadingStep(''); @@ -60,6 +77,31 @@ export default function ApplicationDetailPage({ params }: any) { if (subscriptionUuid) fetchAppName(); }, [subscriptionUuid, typedParams.uuid]); + // Show authorization error if user doesn't have access + if (authError) { + return ( +
+
+
+

Access Denied

+

{authError}

+

+ If you believe you should have access to this application, please contact your administrator. +

+ +
+
+
+ ); + } + const fetchAppDetail = async () => { setLoading(true); setLoadingStep('Fetching analytics data...'); @@ -80,10 +122,11 @@ export default function ApplicationDetailPage({ params }: any) { fetch(`/api/acquia/visits?${dailyQuery}`), ]); - const [dailyViewsRaw, dailyVisitsRaw]: [AcquiaApiResponse, AcquiaApiResponse] = await Promise.all([ - dailyViewsRes.ok ? dailyViewsRes.json() : {}, - dailyVisitsRes.ok ? dailyVisitsRes.json() : {}, - ]); + // Handle authorization errors + const dailyViewsRaw = await handleApiResponse(dailyViewsRes); + const dailyVisitsRaw = await handleApiResponse(dailyVisitsRes); + + if (!dailyViewsRaw || !dailyVisitsRaw) return; // Authorization error handled // Helper to process and aggregate daily data with proper types const processDailyData = (rawData: AcquiaApiResponse, metric: 'views' | 'visits'): DailyDataPoint[] => { @@ -115,8 +158,8 @@ export default function ApplicationDetailPage({ params }: any) { const overallViewsData = dailyViewsRaw.data || []; const overallVisitsData = dailyVisitsRaw.data || []; - const overallTotalViews = overallViewsData.reduce((sum, v) => sum + (v.views || 0), 0); - const overallTotalVisits = overallVisitsData.reduce((sum, v) => sum + (v.visits || 0), 0); + const overallTotalViews = overallViewsData.reduce((sum: number, v: any) => sum + (v.views || 0), 0); + const overallTotalVisits = overallVisitsData.reduce((sum: number, v: any) => sum + (v.visits || 0), 0); setViews(appTotalViews); setVisits(appTotalVisits); diff --git a/docs/SAML.md b/docs/SAML.md index ea18907..027e7be 100644 --- a/docs/SAML.md +++ b/docs/SAML.md @@ -519,6 +519,142 @@ export async function GET(request: NextRequest) { } ``` +## Authorization System + +After successful SAML authentication, CHURRO implements a comprehensive two-tier authorization system to control access to resources. + +### Authorization Levels + +1. **Global Access**: Users with specific `eduPersonEntitlement` values can access the entire application +2. **Per-Application Access**: SUNet ID-based mappings grant access to specific applications only + +### Environment Variables + +Add these to your `.env.local`: + +```env +# Global access via Stanford entitlements +CHURRO_GLOBAL_ENTITLEMENTS=uit:sws + +# Per-application access mappings (format: uuid:uid1,uid2;uuid:uid3) +CHURRO_APP_ACCESS=app-uuid-1:jdoe,jsmith;app-uuid-2:jdoe +``` + +### Configuration Examples + +#### Global Entitlement Access +```env +# All users with "uit:sws" entitlement can access everything +CHURRO_GLOBAL_ENTITLEMENTS=uit:sws + +# Multiple entitlements (comma-separated) +CHURRO_GLOBAL_ENTITLEMENTS=uit:sws,uit:admin,stanford:faculty +``` + +#### Per-Application Access Mappings +```env +# Format: applicationUuid:uid1,uid2,uid3;anotherUuid:uid4,uid5 +CHURRO_APP_ACCESS=12345678-1234-1234-1234-123456789abc:jdoe,jsmith;87654321-4321-4321-4321-cba987654321:jdoe +``` + +### Authorization Components + +#### 1. Authorization Utilities (`lib/auth-utils.ts`) + +Core functions for access checking: + +```typescript +import { hasGlobalAccess, hasApplicationAccess, hasDashboardAccess } from '@/lib/auth-utils'; + +// Check if user has global access via entitlements +const canAccessAll = hasGlobalAccess(user); + +// Check access to specific application +const canAccessApp = hasApplicationAccess(user, 'app-uuid-here'); + +// Check if user can access dashboard (global OR any app) +const canAccessDashboard = hasDashboardAccess(user); +``` + +#### 2. Middleware Protection (`middleware.ts`) + +Automatic route-level authorization enforcement: + +- **Dashboard**: `/` - Requires global access OR access to at least one application +- **Applications**: `/applications/[uuid]` - Requires global access OR specific application access +- **API Routes**: Protected via `withApiAuthorization` wrapper + +#### 3. API Authorization (`lib/api-auth.ts`) + +Server-side API route protection: + +```typescript +import { withApiAuthorization } from '@/lib/api-auth'; + +export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { + // Your protected API logic here + const { user } = context; + return NextResponse.json({ data: 'Protected data' }); + })(request); +} +``` + +### Access Control Flow + +1. **Authentication**: User authenticates via Stanford SAML +2. **Global Check**: System checks if user has global entitlement (e.g., `uit:sws`) +3. **Per-App Check**: If no global access, check specific application mappings +4. **Route Protection**: Middleware enforces access rules before rendering pages +5. **API Protection**: API routes verify authorization before processing requests + +### User Experience + +#### Global Access Users +- Can access dashboard showing all applications +- Can view any application detail page +- Have full access to all API endpoints + +#### Per-Application Access Users +- Can access dashboard (shows only their authorized applications) +- Can only view detail pages for applications they have access to +- API calls filtered to only return authorized application data + +#### Unauthorized Users +- Receive 403 Forbidden with clear error messages +- Redirected to appropriate pages with access denial information +- Provided contact information for requesting access + +### Debugging Authorization + +Enable authorization debugging: + +```typescript +// In lib/auth-utils.ts, uncomment debug logs +console.log('🔐 Authorization check:', { hasGlobal, hasApp, result }); +``` + +Common debug scenarios: + +```bash +# Check user's entitlements +console.log('User entitlements:', user.entitlements); + +# Check parsed app mappings +console.log('App access mappings:', parseAppAccessMappings()); + +# Check specific access decision +console.log('Access decision for app XYZ:', hasApplicationAccess(user, 'xyz-uuid')); +``` + +### Configuration Best Practices + +1. **Production Security**: Never commit real SUNet IDs or UUIDs to version control +2. **Entitlement Standards**: Use Stanford's standard entitlement format (`department:role`) +3. **UUID Management**: Use actual Acquia application UUIDs from the API +4. **Access Reviews**: Regularly audit access mappings in production +5. **Error Handling**: Provide clear error messages for authorization failures + ## Stanford Attribute Mapping The implementation automatically maps Stanford's SAML attributes using official OID identifiers: diff --git a/lib/api-auth.ts b/lib/api-auth.ts new file mode 100644 index 0000000..ab3c143 --- /dev/null +++ b/lib/api-auth.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getCurrentUser, hasApplicationAccess, hasGlobalAccess } from '@/lib/auth-utils' + +/** + * Middleware helper for API route authorization + * + * This provides authorization checking specifically for API routes. + * Use this in individual API route handlers where you need to check + * user permissions before processing the request. + */ + +/** + * Check if the current user is authorized to access API endpoints + * @param request NextRequest object + * @param requireGlobalAccess If true, require global access (not just app-specific) + * @returns Promise<{authorized: boolean, user: SamlUser | null, error?: string}> + */ +export async function checkApiAuthorization( + request: NextRequest, + requireGlobalAccess = false +) { + try { + const user = await getCurrentUser() + + if (!user) { + return { + authorized: false, + user: null, + error: 'Authentication required' + } + } + + if (requireGlobalAccess) { + const hasGlobal = hasGlobalAccess(user) + if (!hasGlobal) { + return { + authorized: false, + user, + error: 'Global access required. Contact administrator for access.' + } + } + } + + return { + authorized: true, + user + } + } catch (error) { + return { + authorized: false, + user: null, + error: 'Authorization check failed' + } + } +} + +/** + * Check if the current user is authorized to access a specific application via API + * @param request NextRequest object + * @param applicationUuid Application UUID to check access for + * @returns Promise<{authorized: boolean, user: SamlUser | null, error?: string}> + */ +export async function checkApplicationApiAuthorization( + request: NextRequest, + applicationUuid: string +) { + try { + const user = await getCurrentUser() + + if (!user) { + return { + authorized: false, + user: null, + error: 'Authentication required' + } + } + + const hasAccess = hasApplicationAccess(user, applicationUuid) + if (!hasAccess) { + return { + authorized: false, + user, + error: `Access denied. You do not have permission to access application ${applicationUuid}.` + } + } + + return { + authorized: true, + user + } + } catch (error) { + return { + authorized: false, + user: null, + error: 'Authorization check failed' + } + } +} + +/** + * Create a standardized unauthorized response + * @param error Error message + * @param status HTTP status code (default: 403) + * @returns NextResponse with error + */ +export function createUnauthorizedResponse(error: string, status = 403) { + return NextResponse.json( + { + error, + code: status === 401 ? 'AUTHENTICATION_REQUIRED' : 'ACCESS_DENIED' + }, + { status } + ) +} + +/** + * Wrapper function to protect API routes with authorization + * Usage: + * + * export const GET = withApiAuthorization(async (request, { user }) => { + * // Your API logic here, user is guaranteed to be authenticated and authorized + * return NextResponse.json({ data: 'authorized data' }) + * }, { requireGlobalAccess: true }) + */ +export function withApiAuthorization( + handler: (request: NextRequest, context: { user: NonNullable>> }) => Promise, + options: { requireGlobalAccess?: boolean; requireApplicationUuid?: boolean } = {} +) { + return async (request: NextRequest) => { + // Check if application UUID is required and extract it from URL + let applicationUuid: string | undefined + + if (options.requireApplicationUuid) { + const url = new URL(request.url) + // Try to extract UUID from path segments or query params + const pathSegments = url.pathname.split('/') + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + applicationUuid = pathSegments.find(segment => uuidRegex.test(segment)) || + url.searchParams.get('uuid') || + url.searchParams.get('applicationUuid') || + undefined + + if (!applicationUuid) { + return createUnauthorizedResponse('Application UUID required', 400) + } + } + + // Perform authorization check + const authResult = applicationUuid + ? await checkApplicationApiAuthorization(request, applicationUuid) + : await checkApiAuthorization(request, options.requireGlobalAccess) + + if (!authResult.authorized || !authResult.user) { + const status = authResult.error?.includes('Authentication required') ? 401 : 403 + return createUnauthorizedResponse(authResult.error || 'Access denied', status) + } + + // Call the actual handler with the authorized user + return handler(request, { user: authResult.user }) + } +} \ No newline at end of file diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts index 205a8b5..c9ac0ee 100644 --- a/lib/auth-utils.ts +++ b/lib/auth-utils.ts @@ -1,5 +1,199 @@ import { getJWTCookieName, type SamlUser } from '@/lib/jwt-auth' +/** + * Authorization utilities for CHURRO application + * + * Implements two-tier access control: + * 1. Global access via eduPersonEntitlement (e.g., 'uit:sws') + * 2. Per-application access via uid mappings + */ + +/** + * Get authorized eduPersonEntitlement values from environment variables + * Format: CHURRO_GLOBAL_ENTITLEMENTS=uit:sws,other:entitlement + */ +export function getAuthorizedEntitlements(): string[] { + const entitlements = process.env.CHURRO_GLOBAL_ENTITLEMENTS + if (!entitlements) { + return [] + } + + return entitlements + .split(',') + .map(e => e.trim()) + .filter(e => e.length > 0) +} + +/** + * Parse per-application UID mappings from environment variables + * Format: CHURRO_APP_ACCESS=uuid1:uid1,uid2;uuid2:uid3,uid4 + * Returns Map> + */ +export function parseAppAccessMappings(): Map> { + const mappings = new Map>() + const accessString = process.env.CHURRO_APP_ACCESS + + if (!accessString) { + return mappings + } + + try { + // Split by semicolon to get app mappings: "uuid1:uid1,uid2;uuid2:uid3,uid4" + const appMappings = accessString.split(';') + + for (const mapping of appMappings) { + const [appUuid, uidsString] = mapping.split(':') + + if (appUuid && uidsString) { + const uids = uidsString + .split(',') + .map(uid => uid.trim()) + .filter(uid => uid.length > 0) + + mappings.set(appUuid.trim(), new Set(uids)) + } + } + } catch (error) { + console.warn('Failed to parse CHURRO_APP_ACCESS environment variable:', error) + } + + return mappings +} + +/** + * Check if user has global access to the application + * @param user SAML user object + * @returns true if user has global access via eduPersonEntitlement + */ +export function hasGlobalAccess(user: SamlUser): boolean { + const authorizedEntitlements = getAuthorizedEntitlements() + + if (authorizedEntitlements.length === 0) { + // No global entitlements configured - deny access + return false + } + + if (!user.eduPersonEntitlement) { + // User has no entitlements + return false + } + + // User may have multiple entitlements, check if any match + const userEntitlements = Array.isArray(user.eduPersonEntitlement) + ? user.eduPersonEntitlement + : [user.eduPersonEntitlement] + + return userEntitlements.some(entitlement => + authorizedEntitlements.includes(entitlement) + ) +} + +/** + * Check if user has access to a specific application + * @param user SAML user object + * @param applicationUuid Application UUID to check access for + * @returns true if user has access (either global or per-app) + */ +export function hasApplicationAccess(user: SamlUser, applicationUuid: string): boolean { + // First check global access + if (hasGlobalAccess(user)) { + return true + } + + // Check per-application access + const appMappings = parseAppAccessMappings() + const authorizedUids = appMappings.get(applicationUuid) + + if (!authorizedUids || authorizedUids.size === 0) { + // No specific access configured for this app + return false + } + + if (!user.sunetId) { + // User has no SUNet ID + return false + } + + return authorizedUids.has(user.sunetId) +} + +/** + * Check if user can access the main dashboard + * For now, this requires either global access OR access to at least one application + * @param user SAML user object + * @returns true if user can access dashboard + */ +export function hasDashboardAccess(user: SamlUser): boolean { + // Global access grants dashboard access + if (hasGlobalAccess(user)) { + return true + } + + // Check if user has access to any application + const appMappings = parseAppAccessMappings() + + if (!user.sunetId) { + return false + } + + // Check if user's UID appears in any application mapping + let hasAnyAccess = false; + appMappings.forEach((authorizedUids) => { + if (authorizedUids.has(user.sunetId!)) { + hasAnyAccess = true; + } + }); + if (hasAnyAccess) { + return true; + } + + return false +} + +/** + * Get list of application UUIDs the user has access to + * @param user SAML user object + * @returns Array of application UUIDs user can access (empty array = all apps for global users) + */ +export function getUserApplicationAccess(user: SamlUser): string[] { + // Global access grants access to all apps + if (hasGlobalAccess(user)) { + return [] // Empty array means "all apps" for global users + } + + if (!user.sunetId) { + return [] + } + + const appMappings = parseAppAccessMappings() + const accessibleApps: string[] = [] + + appMappings.forEach((authorizedUids, appUuid) => { + if (authorizedUids.has(user.sunetId!)) { + accessibleApps.push(appUuid); + } + }); + + return accessibleApps +} + +/** + * Authorization debugging helper + * @param user SAML user object + * @returns Object with authorization details for debugging + */ +export function getAuthorizationDebugInfo(user: SamlUser) { + return { + userId: user.sunetId || user.id, + userEntitlements: user.eduPersonEntitlement, + hasGlobalAccess: hasGlobalAccess(user), + hasDashboardAccess: hasDashboardAccess(user), + accessibleApps: getUserApplicationAccess(user), + configuredEntitlements: getAuthorizedEntitlements(), + configuredAppMappings: Object.fromEntries(parseAppAccessMappings()) + } +} + /** * Get the current authenticated user from cookies (server-side) * For use in Server Components and API routes diff --git a/middleware.ts b/middleware.ts index 422c200..adc160d 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,56 +1,84 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { verifyJWT, getJWTCookieName } from '@/lib/jwt-auth'; +import { hasDashboardAccess, hasApplicationAccess } from '@/lib/auth-utils'; /** - * Middleware for JWT authentication + * Middleware for JWT authentication and authorization * - * DESIGN DECISION: Authentication (AuthN) vs Authorization (AuthZ) + * IMPLEMENTATION: Authentication (AuthN) + Authorization (AuthZ) * - * This middleware currently implements AUTHENTICATION ONLY: + * This middleware implements both AUTHENTICATION and AUTHORIZATION: * - Verifies user identity via JWT tokens from SAML SSO - * - Protects routes with the /protected/* prefix - * - Adds user identity headers for authenticated requests + * - Enforces authorization rules based on eduPersonEntitlement and uid mappings + * - Protects application routes and dashboard based on user permissions * - * API routes (/api/*) are intentionally NOT protected because: - * 1. This branch implements AuthN only - AuthZ is future work - * 2. API routes currently return all data for a given subscription UUID - * 3. There's no user-data association or permission filtering yet - * 4. Main application routes (/, /applications/*) are also public + * Authorization Rules: + * 1. Global Access: Users with configured eduPersonEntitlement (e.g., 'uit:sws') + * can access everything + * 2. Per-App Access: Individual uid mappings grant access to specific applications + * 3. Dashboard Access: Granted if user has global access OR access to at least one app * - * FUTURE AuthZ work should: - * - Associate users with specific applications/data they can access - * - Filter API responses based on user permissions - * - Implement role-based access control (RBAC) - * - Then protect API routes to enforce these permissions + * API routes (/api/*) are intentionally NOT protected in middleware because: + * - API-level authorization is implemented in individual route handlers + * - This allows for more granular permission checking with request context + * - Different APIs may have different authorization requirements */ export async function middleware(request: NextRequest) { const token = request.cookies.get(getJWTCookieName())?.value; + const pathname = request.nextUrl.pathname; - // For protected routes, check authentication - const isProtectedRoute = request.nextUrl.pathname.startsWith('/protected'); + // Check if this is a protected route that requires authentication + const isProtectedRoute = pathname.startsWith('/protected'); - if (isProtectedRoute) { + // Check if this is an application detail page that requires authorization + const isApplicationRoute = pathname.match(/^\/applications\/([a-f0-9-]{36})$/); + + // Check if this is the dashboard that requires authorization + const isDashboardRoute = pathname === '/' || pathname.startsWith('/dashboard'); + + // For routes requiring authentication/authorization + if (isProtectedRoute || isApplicationRoute || isDashboardRoute) { if (!token) { // No token, redirect to SAML login return NextResponse.redirect(new URL('/api/saml/login', request.url)); } // Verify the JWT token - const payload = await verifyJWT(token); - if (!payload) { + const user = await verifyJWT(token); + if (!user) { // Invalid token, redirect to SAML login return NextResponse.redirect(new URL('/api/saml/login', request.url)); } - // Token is valid, add user info to request headers for downstream use + // Authorization checks + if (isApplicationRoute) { + const appUuid = isApplicationRoute[1]; + if (!hasApplicationAccess(user, appUuid)) { + // User doesn't have access to this specific application + return NextResponse.json( + { error: 'Access denied. You do not have permission to view this application.' }, + { status: 403 } + ); + } + } else if (isDashboardRoute) { + if (!hasDashboardAccess(user)) { + // User doesn't have access to dashboard + return NextResponse.json( + { error: 'Access denied. You do not have permission to access this application.' }, + { status: 403 } + ); + } + } + + // Token is valid and user is authorized, add user info to request headers const requestHeaders = new Headers(request.headers); - requestHeaders.set('x-user-id', payload.id); - if (payload.sunetId) { - requestHeaders.set('x-user-sunetid', payload.sunetId); + requestHeaders.set('x-user-id', user.id); + if (user.sunetId) { + requestHeaders.set('x-user-sunetid', user.sunetId); } - if (payload.email) { - requestHeaders.set('x-user-email', payload.email); + if (user.email) { + requestHeaders.set('x-user-email', user.email); } return NextResponse.next({ @@ -70,11 +98,16 @@ export const config = { * * Excludes from middleware processing: * - _next: Next.js internal routes (static files, build assets) - * - api: API routes (intentionally public - see comment above) + * - api: API routes (have individual authorization in route handlers) * - favicon.ico: Browser favicon requests * - * Protected routes must use the /protected/* prefix to require authentication. - * Example: /protected/admin, /protected/dashboard + * Protected routes: + * - /protected/*: Always require authentication + * - /applications/[uuid]: Require app-specific authorization + * - / (dashboard): Require dashboard authorization + * + * Public routes that bypass middleware: + * - /auth/*: Authentication flows (login, test pages) */ - matcher: ['/((?!_next|api|favicon.ico).*)'], + matcher: ['/((?!_next|api|favicon.ico|auth).*)'], }; \ No newline at end of file From 54d759937e014dcb3ff41ddbba961a1e59e68eb0 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 14:13:28 -0800 Subject: [PATCH 02/10] Update logic to block Dashboard access for application-level users --- app/api/auth/logout/route.ts | 13 +++++++++++- app/auth/test/page.tsx | 2 +- lib/auth-utils.ts | 38 ++++++------------------------------ middleware.ts | 10 +++++++--- 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 42625c6..93d6038 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -8,12 +8,23 @@ import { getBaseUrl } from '@/lib/url-utils' */ export async function GET(request: NextRequest) { const cookieStore = await cookies() + const { searchParams } = new URL(request.url) // Delete the JWT cookie cookieStore.delete(getJWTCookieName()) - // Redirect to home page or login page + // Check if redirect URL is specified (e.g., from test page) + const redirectTo = searchParams.get('redirectTo') const baseUrl = getBaseUrl(request) + + if (redirectTo) { + // Validate that redirect is to a safe path (starts with /) + if (redirectTo.startsWith('/')) { + return NextResponse.redirect(new URL(redirectTo, baseUrl)) + } + } + + // Default redirect to home page return NextResponse.redirect(new URL('/', baseUrl)) } diff --git a/app/auth/test/page.tsx b/app/auth/test/page.tsx index cbe4424..d35138b 100644 --- a/app/auth/test/page.tsx +++ b/app/auth/test/page.tsx @@ -53,7 +53,7 @@ function AuthTestContent() { } const handleLogout = () => { - window.location.href = '/api/auth/logout' + window.location.href = '/api/auth/logout?redirectTo=/auth/test' } if (loading) { diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts index c9ac0ee..c6ed308 100644 --- a/lib/auth-utils.ts +++ b/lib/auth-utils.ts @@ -86,9 +86,7 @@ export function hasGlobalAccess(user: SamlUser): boolean { return userEntitlements.some(entitlement => authorizedEntitlements.includes(entitlement) ) -} - -/** +}/** * Check if user has access to a specific application * @param user SAML user object * @param applicationUuid Application UUID to check access for @@ -119,38 +117,14 @@ export function hasApplicationAccess(user: SamlUser, applicationUuid: string): b /** * Check if user can access the main dashboard - * For now, this requires either global access OR access to at least one application + * Dashboard access requires global access only - per-app users should go directly to their apps * @param user SAML user object - * @returns true if user can access dashboard + * @returns true if user can access dashboard (global access only) */ export function hasDashboardAccess(user: SamlUser): boolean { - // Global access grants dashboard access - if (hasGlobalAccess(user)) { - return true - } - - // Check if user has access to any application - const appMappings = parseAppAccessMappings() - - if (!user.sunetId) { - return false - } - - // Check if user's UID appears in any application mapping - let hasAnyAccess = false; - appMappings.forEach((authorizedUids) => { - if (authorizedUids.has(user.sunetId!)) { - hasAnyAccess = true; - } - }); - if (hasAnyAccess) { - return true; - } - - return false -} - -/** + // Only users with global access can see the dashboard + return hasGlobalAccess(user) +}/** * Get list of application UUIDs the user has access to * @param user SAML user object * @returns Array of application UUIDs user can access (empty array = all apps for global users) diff --git a/middleware.ts b/middleware.ts index adc160d..5e9f439 100644 --- a/middleware.ts +++ b/middleware.ts @@ -17,7 +17,8 @@ import { hasDashboardAccess, hasApplicationAccess } from '@/lib/auth-utils'; * 1. Global Access: Users with configured eduPersonEntitlement (e.g., 'uit:sws') * can access everything * 2. Per-App Access: Individual uid mappings grant access to specific applications - * 3. Dashboard Access: Granted if user has global access OR access to at least one app + * 3. Dashboard Access: Granted only to users with global access + * (per-app users should go directly to their authorized applications) * * API routes (/api/*) are intentionally NOT protected in middleware because: * - API-level authorization is implemented in individual route handlers @@ -37,8 +38,11 @@ export async function middleware(request: NextRequest) { // Check if this is the dashboard that requires authorization const isDashboardRoute = pathname === '/' || pathname.startsWith('/dashboard'); - // For routes requiring authentication/authorization - if (isProtectedRoute || isApplicationRoute || isDashboardRoute) { + // Exclude auth test pages from all protection + const isAuthTestRoute = pathname.startsWith('/auth/'); + + // For routes requiring authentication/authorization (excluding auth test routes) + if (!isAuthTestRoute && (isProtectedRoute || isApplicationRoute || isDashboardRoute)) { if (!token) { // No token, redirect to SAML login return NextResponse.redirect(new URL('/api/saml/login', request.url)); From ded1b2435a62786aa66b31e0f5716441185f9840 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 14:29:48 -0800 Subject: [PATCH 03/10] fixup! npm update next eslint-config-next --- package-lock.json | 78 +++++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddab3f3..ddfe34e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -705,9 +705,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", - "integrity": "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", + "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -721,9 +721,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.2.tgz", - "integrity": "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -737,9 +737,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.2.tgz", - "integrity": "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -753,9 +753,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.2.tgz", - "integrity": "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -769,9 +769,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.2.tgz", - "integrity": "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -785,9 +785,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.2.tgz", - "integrity": "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -801,9 +801,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.2.tgz", - "integrity": "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -817,9 +817,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.2.tgz", - "integrity": "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -833,9 +833,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.2.tgz", - "integrity": "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -4905,12 +4905,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", - "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", + "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "license": "MIT", "dependencies": { - "@next/env": "15.5.2", + "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -4923,14 +4923,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.2", - "@next/swc-darwin-x64": "15.5.2", - "@next/swc-linux-arm64-gnu": "15.5.2", - "@next/swc-linux-arm64-musl": "15.5.2", - "@next/swc-linux-x64-gnu": "15.5.2", - "@next/swc-linux-x64-musl": "15.5.2", - "@next/swc-win32-arm64-msvc": "15.5.2", - "@next/swc-win32-x64-msvc": "15.5.2", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { From 66148b657169e469c7df1af709e42d001223a3a1 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:11:25 -0800 Subject: [PATCH 04/10] update to handle both POST and GET handlers --- app/api/saml/acs/route.ts | 179 +++++++++++++++++++++++--------------- 1 file changed, 110 insertions(+), 69 deletions(-) diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index 1cb0e4c..c4d9431 100644 --- a/app/api/saml/acs/route.ts +++ b/app/api/saml/acs/route.ts @@ -4,92 +4,133 @@ import { generateJWT, getJWTCookieName, getSecureCookieOptions, type SamlUser } import { getBaseUrl } from '@/lib/url-utils' import { cookies } from 'next/headers' -export async function POST(request: NextRequest) { - try { - const formData = await request.formData() - const samlResponse = formData.get('SAMLResponse') as string +/** + * Common SAML response processing logic for both POST and GET handlers + */ +async function processSamlResponse(request: NextRequest, samlResponse: string) { + console.log('🔍 Processing SAML response with @node-saml/node-saml...') - if (!samlResponse) { - throw new Error('No SAML response received') + const { profile } = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse }) + + if (!profile) { + throw new Error('No profile returned from SAML response') + } + + console.log('✅ SAML validation succeeded!') + console.log('📋 Profile summary:', { + nameID: profile.nameID, + email: profile.email, + sunetId: profile.sunetId, + attributes: Object.keys(profile.attributes || {}), + }) + + const attributes = (profile.attributes || {}) as Record + + // Helper function to get attribute value (handles both single values and arrays) + const getAttr = (key: string): string | undefined => { + const value = attributes[key] + if (Array.isArray(value)) { + return value[0] as string } + return value as string | undefined + } - console.log('🔍 Processing SAML response with @node-saml/node-saml...') + const user: SamlUser = { + id: profile.nameID || 'unknown-id', + + // Core Stanford Identity + sunetId: getAttr('urn:oid:0.9.2342.19200300.100.1.1'), + email: getAttr('urn:oid:0.9.2342.19200300.100.1.3'), + eduPersonPrincipalName: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.6'), + + // Name + firstName: getAttr('urn:oid:2.5.4.42'), + lastName: getAttr('urn:oid:2.5.4.4'), + displayName: getAttr('urn:oid:2.16.840.1.113730.3.1.241'), + name: getAttr('urn:oid:2.16.840.1.113730.3.1.241') || + `${getAttr('urn:oid:2.5.4.42') || ''} ${getAttr('urn:oid:2.5.4.4') || ''}`.trim() || + 'Stanford User', + + // Affiliations + eduPersonAffiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.1'), + eduPersonScopedAffiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.9'), + suAffiliation: getAttr('suAffiliation'), + affiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.1') || getAttr('suAffiliation'), + + // Other + eduPersonEntitlement: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.7'), + eduPersonOrcid: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.16'), + subjectId: getAttr('urn:oasis:names:tc:SAML:attribute:subject-id'), + pairwiseId: getAttr('urn:oasis:names:tc:SAML:attribute:pairwise-id'), + + authenticationTime: new Date().toISOString(), + allAttributes: attributes, + } - const { profile } = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse }) + console.log('✅ Successfully parsed user:', user.sunetId || user.email || user.id) - if (!profile) { - throw new Error('No profile returned from SAML response') - } + // Generate JWT token from the SAML profile + const jwtToken = await generateJWT(user) - console.log('✅ SAML validation succeeded!') - console.log('📋 Profile summary:', { - nameID: profile.nameID, - email: profile.email, - sunetId: profile.sunetId, - attributes: Object.keys(profile.attributes || {}), - }) - - const attributes = (profile.attributes || {}) as Record - - // Helper function to get attribute value (handles both single values and arrays) - const getAttr = (key: string): string | undefined => { - const value = attributes[key] - if (Array.isArray(value)) { - return value[0] as string - } - return value as string | undefined - } + // Set the JWT as a secure HTTP-only cookie + const cookieStore = await cookies() + cookieStore.set(getJWTCookieName(), jwtToken, getSecureCookieOptions()) - const user: SamlUser = { - id: profile.nameID || 'unknown-id', - - // Core Stanford Identity - sunetId: getAttr('urn:oid:0.9.2342.19200300.100.1.1'), - email: getAttr('urn:oid:0.9.2342.19200300.100.1.3'), - eduPersonPrincipalName: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.6'), - - // Name - firstName: getAttr('urn:oid:2.5.4.42'), - lastName: getAttr('urn:oid:2.5.4.4'), - displayName: getAttr('urn:oid:2.16.840.1.113730.3.1.241'), - name: getAttr('urn:oid:2.16.840.1.113730.3.1.241') || - `${getAttr('urn:oid:2.5.4.42') || ''} ${getAttr('urn:oid:2.5.4.4') || ''}`.trim() || - 'Stanford User', - - // Affiliations - eduPersonAffiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.1'), - eduPersonScopedAffiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.9'), - suAffiliation: getAttr('suAffiliation'), - affiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.1') || getAttr('suAffiliation'), - - // Other - eduPersonEntitlement: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.7'), - eduPersonOrcid: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.16'), - subjectId: getAttr('urn:oasis:names:tc:SAML:attribute:subject-id'), - pairwiseId: getAttr('urn:oasis:names:tc:SAML:attribute:pairwise-id'), - - authenticationTime: new Date().toISOString(), - allAttributes: attributes, - } + // Redirect to the application (or a relay state if available) + const baseUrl = getBaseUrl(request) + const redirectUrl = new URL('/auth/test', baseUrl) + redirectUrl.searchParams.set('saml_success', 'true') - console.log('✅ Successfully parsed user:', user.sunetId || user.email || user.id) + return Response.redirect(redirectUrl.toString(), 302) +} - // Generate JWT token from the SAML profile - const jwtToken = await generateJWT(user) +/** + * Handle SAML responses via HTTP-POST binding (form data) + */ +/** + * Handle SAML responses via HTTP-POST binding (form data) + */ +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const samlResponse = formData.get('SAMLResponse') as string + + if (!samlResponse) { + throw new Error('No SAML response received in form data') + } - // Set the JWT as a secure HTTP-only cookie - const cookieStore = await cookies() - cookieStore.set(getJWTCookieName(), jwtToken, getSecureCookieOptions()) + console.log('📨 POST: Processing SAML response from form data') + return await processSamlResponse(request, samlResponse) + + } catch (error) { + console.error('❌ SAML POST callback error:', error) + console.error('❌ Error stack:', error instanceof Error ? error.stack : 'No stack') - // Redirect to the application (or a relay state if available) const baseUrl = getBaseUrl(request) const redirectUrl = new URL('/auth/test', baseUrl) - redirectUrl.searchParams.set('saml_success', 'true') + redirectUrl.searchParams.set('saml_error', String(error)) return Response.redirect(redirectUrl.toString(), 302) + } +} + +/** + * Handle SAML responses via HTTP-Redirect binding (query parameters) + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const samlResponse = searchParams.get('SAMLResponse') + + if (!samlResponse) { + throw new Error('No SAML response received in query parameters') + } + + console.log('🔗 GET: Processing SAML response from query parameters') + return await processSamlResponse(request, samlResponse) } catch (error) { - console.error('❌ SAML callback error:', error) + console.error('❌ SAML GET callback error:', error) console.error('❌ Error stack:', error instanceof Error ? error.stack : 'No stack') const baseUrl = getBaseUrl(request) From 70d54fda2a85de8f8eee7443c80263a7fd166438 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:16:32 -0800 Subject: [PATCH 05/10] fixup! debug --- app/api/saml/acs/route.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index c4d9431..1df1ac6 100644 --- a/app/api/saml/acs/route.ts +++ b/app/api/saml/acs/route.ts @@ -120,10 +120,29 @@ export async function POST(request: NextRequest) { export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) + + // Debug: Log all query parameters + console.log('🔍 GET request query parameters:') + const allParams: string[] = [] + searchParams.forEach((value, key) => { + console.log(` ${key}: ${value?.substring(0, 100)}${value?.length > 100 ? '...' : ''}`) + allParams.push(key) + }) + const samlResponse = searchParams.get('SAMLResponse') if (!samlResponse) { - throw new Error('No SAML response received in query parameters') + // Check for alternative parameter names that Stanford might use + const altResponse = searchParams.get('samlResponse') || + searchParams.get('Response') || + searchParams.get('saml_response') + + if (altResponse) { + console.log('🔗 GET: Found SAML response with alternative parameter name') + return await processSamlResponse(request, altResponse) + } + + throw new Error(`No SAML response received in query parameters. Available parameters: ${allParams.join(', ')}`) } console.log('🔗 GET: Processing SAML response from query parameters') From 732e03c46cf402fa82bcd972b0f2e4650160df7e Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:26:36 -0800 Subject: [PATCH 06/10] fixup! debug: add vercel.json to disable auth on API endpoint --- vercel.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..1c16918 --- /dev/null +++ b/vercel.json @@ -0,0 +1,7 @@ +{ + "functions": { + "app/api/saml/**": { + "excludeFromAuth": true + } + } +} \ No newline at end of file From 1f3a8f5397ea3040d909409793198457843586cb Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:31:20 -0800 Subject: [PATCH 07/10] fixup! revert --- vercel.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 vercel.json diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 1c16918..0000000 --- a/vercel.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "functions": { - "app/api/saml/**": { - "excludeFromAuth": true - } - } -} \ No newline at end of file From d2507a9c73adfe9607f3e7e5cd113c5b73c5a995 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:36:53 -0800 Subject: [PATCH 08/10] fixup! WIP --- app/api/saml/acs/route.ts | 46 --------------------------------------- 1 file changed, 46 deletions(-) diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index 1df1ac6..217ec4b 100644 --- a/app/api/saml/acs/route.ts +++ b/app/api/saml/acs/route.ts @@ -110,52 +110,6 @@ export async function POST(request: NextRequest) { const redirectUrl = new URL('/auth/test', baseUrl) redirectUrl.searchParams.set('saml_error', String(error)) - return Response.redirect(redirectUrl.toString(), 302) - } -} - -/** - * Handle SAML responses via HTTP-Redirect binding (query parameters) - */ -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url) - - // Debug: Log all query parameters - console.log('🔍 GET request query parameters:') - const allParams: string[] = [] - searchParams.forEach((value, key) => { - console.log(` ${key}: ${value?.substring(0, 100)}${value?.length > 100 ? '...' : ''}`) - allParams.push(key) - }) - - const samlResponse = searchParams.get('SAMLResponse') - - if (!samlResponse) { - // Check for alternative parameter names that Stanford might use - const altResponse = searchParams.get('samlResponse') || - searchParams.get('Response') || - searchParams.get('saml_response') - - if (altResponse) { - console.log('🔗 GET: Found SAML response with alternative parameter name') - return await processSamlResponse(request, altResponse) - } - - throw new Error(`No SAML response received in query parameters. Available parameters: ${allParams.join(', ')}`) - } - - console.log('🔗 GET: Processing SAML response from query parameters') - return await processSamlResponse(request, samlResponse) - - } catch (error) { - console.error('❌ SAML GET callback error:', error) - console.error('❌ Error stack:', error instanceof Error ? error.stack : 'No stack') - - const baseUrl = getBaseUrl(request) - const redirectUrl = new URL('/auth/test', baseUrl) - redirectUrl.searchParams.set('saml_error', String(error)) - return Response.redirect(redirectUrl.toString(), 302) } } \ No newline at end of file From 384d45ca0c03def8507119c5b30f5cc538a78987 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Wed, 10 Dec 2025 15:16:08 -0800 Subject: [PATCH 09/10] Update AuthZ for applications page --- app/applications/page.tsx | 102 +++++++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/app/applications/page.tsx b/app/applications/page.tsx index e6421d2..66f1e97 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -1,47 +1,64 @@ import React from 'react'; +import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { getCurrentUser, hasGlobalAccess, hasApplicationAccess } from '@/lib/auth-utils'; +import { redirect } from 'next/navigation'; // Force dynamic rendering - don't try to pre-render at build time export const dynamic = 'force-dynamic'; -const BASE_URL = process.env.VERCEL_URL - ? `https://${process.env.VERCEL_URL}` - : process.env.NEXT_PUBLIC_BASE_URL - ? process.env.NEXT_PUBLIC_BASE_URL - : 'http://localhost:3000'; - async function fetchData() { - const [appsRes, viewsRes, visitsRes] = await Promise.all([ - fetch(`${BASE_URL}/api/acquia/applications`), - fetch(`${BASE_URL}/api/acquia/views`), - fetch(`${BASE_URL}/api/acquia/visits`), - ]); - const [apps, viewsRaw, visitsRaw] = await Promise.all([ - appsRes.ok ? appsRes.json() : [], - viewsRes.ok ? viewsRes.json() : [], - visitsRes.ok ? visitsRes.json() : [], + // Check authentication first + const user = await getCurrentUser(); + if (!user) { + redirect('/api/saml/login'); + } + + // Initialize API service + const apiService = new AcquiaApiServiceFixed({ + baseUrl: process.env.ACQUIA_API_BASE_URL || 'https://cloud.acquia.com/api', + authUrl: process.env.ACQUIA_AUTH_BASE_URL || 'https://accounts.acquia.com/api', + apiKey: process.env.ACQUIA_API_KEY!, + apiSecret: process.env.ACQUIA_API_SECRET!, + }); + + // Fetch all applications first + const [allApps, views, visits] = await Promise.all([ + apiService.getApplications(), + apiService.getViewsDataByApplication(process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID!), + apiService.getVisitsDataByApplication(process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID!), ]); - // Defensive: ensure arrays - const views = Array.isArray(viewsRaw) - ? viewsRaw - : viewsRaw && Array.isArray(viewsRaw.data) - ? viewsRaw.data - : []; - const visits = Array.isArray(visitsRaw) - ? visitsRaw - : visitsRaw && Array.isArray(visitsRaw.data) - ? visitsRaw.data - : []; - return { apps, views, visits }; + // Filter applications based on user permissions + const apps = hasGlobalAccess(user) + ? allApps || [] + : (allApps || []).filter(app => hasApplicationAccess(user, app.uuid)); + + return { + apps, + views: views || [], + visits: visits || [], + user + }; } -function getAppStats(apps: any[], views: { map: (arg0: (v: any) => any[]) => Iterable; reduce: (arg0: (sum: any, v: any) => any, arg1: number) => any; }, visits: { map: (arg0: (v: any) => any[]) => Iterable; reduce: (arg0: (sum: any, v: any) => any, arg1: number) => any; }) { - // Map views/visits by app uuid - const viewsByApp = Object.fromEntries(views.map(v => [v.uuid, v.views])); - const visitsByApp = Object.fromEntries(visits.map(v => [v.uuid, v.visits])); +function getAppStats(apps: any[], views: any[], visits: any[]) { + // Aggregate views/visits by app uuid (sum across all dates) + const viewsByApp: Record = {}; + const visitsByApp: Record = {}; + + views.forEach(v => { + const uuid = v.applicationUuid || v.uuid; + viewsByApp[uuid] = (viewsByApp[uuid] || 0) + v.views; + }); + + visits.forEach(v => { + const uuid = v.applicationUuid || v.uuid; + visitsByApp[uuid] = (visitsByApp[uuid] || 0) + v.visits; + }); + // Calculate totals - const totalViews = views.reduce((sum, v) => sum + v.views, 0); - const totalVisits = visits.reduce((sum, v) => sum + v.visits, 0); + const totalViews = Object.values(viewsByApp).reduce((sum, v) => sum + v, 0); + const totalVisits = Object.values(visitsByApp).reduce((sum, v) => sum + v, 0); // Merge stats return apps.map(app => ({ @@ -54,9 +71,26 @@ function getAppStats(apps: any[], views: { map: (arg0: (v: any) => any[]) => Ite } export default async function ApplicationsPage() { - const { apps, views, visits } = await fetchData(); + const { apps, views, visits, user } = await fetchData(); const stats = getAppStats(apps, views, visits); + if (stats.length === 0) { + return ( +
+

Application Views & Visits

+
+

No Applications Available

+

+ You don't have access to any applications. Contact your administrator if you believe this is an error. +

+

+ Logged in as: {user?.sunetId || user?.email || 'Unknown user'} +

+
+
+ ); + } + return (

Application Views & Visits

From 44b6b9f25c993b4c96894423f237f3e550f2e8ca Mon Sep 17 00:00:00 2001 From: John Bickar Date: Wed, 10 Dec 2025 15:57:24 -0800 Subject: [PATCH 10/10] Fixes to applications overview page --- app/applications/page.tsx | 432 +++++++++++++++++++++++++++++++------- 1 file changed, 352 insertions(+), 80 deletions(-) diff --git a/app/applications/page.tsx b/app/applications/page.tsx index 66f1e97..e7d34d8 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -1,89 +1,311 @@ -import React from 'react'; -import AcquiaApiServiceFixed from '@/lib/acquia-api'; -import { getCurrentUser, hasGlobalAccess, hasApplicationAccess } from '@/lib/auth-utils'; -import { redirect } from 'next/navigation'; - -// Force dynamic rendering - don't try to pre-render at build time -export const dynamic = 'force-dynamic'; - -async function fetchData() { - // Check authentication first - const user = await getCurrentUser(); - if (!user) { - redirect('/api/saml/login'); - } +'use client'; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import CountUpTimer from '@/components/CountUpTimer'; + +// Helper function to get previous month date range +function getPreviousMonthRange() { + const now = new Date(); + const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastDayOfPreviousMonth = new Date(now.getFullYear(), now.getMonth(), 0); + + // Format dates as YYYY-MM-DD + const from = previousMonth.toISOString().split('T')[0]; + const to = lastDayOfPreviousMonth.toISOString().split('T')[0]; - // Initialize API service - const apiService = new AcquiaApiServiceFixed({ - baseUrl: process.env.ACQUIA_API_BASE_URL || 'https://cloud.acquia.com/api', - authUrl: process.env.ACQUIA_AUTH_BASE_URL || 'https://accounts.acquia.com/api', - apiKey: process.env.ACQUIA_API_KEY!, - apiSecret: process.env.ACQUIA_API_SECRET!, + // Get formatted month name for display + const monthName = previousMonth.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric' }); - // Fetch all applications first - const [allApps, views, visits] = await Promise.all([ - apiService.getApplications(), - apiService.getViewsDataByApplication(process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID!), - apiService.getVisitsDataByApplication(process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID!), - ]); - - // Filter applications based on user permissions - const apps = hasGlobalAccess(user) - ? allApps || [] - : (allApps || []).filter(app => hasApplicationAccess(user, app.uuid)); - - return { - apps, - views: views || [], - visits: visits || [], - user - }; + return { from, to, monthName }; +} + +interface Application { + uuid: string; + name: string; +} + +interface AppStats extends Application { + views: number; + visits: number; + viewsPct: number; + visitsPct: number; } -function getAppStats(apps: any[], views: any[], visits: any[]) { +interface User { + sunetId?: string; + email?: string; +} + +function getAppStats(apps: Application[], views: any[], visits: any[]): AppStats[] { // Aggregate views/visits by app uuid (sum across all dates) const viewsByApp: Record = {}; const visitsByApp: Record = {}; - views.forEach(v => { - const uuid = v.applicationUuid || v.uuid; - viewsByApp[uuid] = (viewsByApp[uuid] || 0) + v.views; + // Ensure views and visits are arrays + const viewsArray = Array.isArray(views) ? views : []; + const visitsArray = Array.isArray(visits) ? visits : []; + + console.log('🔍 Processing views data:', viewsArray.length, 'items'); + console.log('🔍 Processing visits data:', visitsArray.length, 'items'); + if (viewsArray.length > 0) { + console.log('📊 Sample views item:', JSON.stringify(viewsArray[0], null, 2)); + console.log('📊 Views item keys:', Object.keys(viewsArray[0])); + } + if (visitsArray.length > 0) { + console.log('📊 Sample visits item:', JSON.stringify(visitsArray[0], null, 2)); + console.log('📊 Visits item keys:', Object.keys(visitsArray[0])); + } + + viewsArray.forEach((v, index) => { + const uuid = v.applicationUuid; + const viewCount = v.views || 0; + if (index < 3) { + console.log(`📈 Processing views item ${index}:`, { uuid, viewCount, hasUuid: !!uuid, hasViews: !!v.views, item: JSON.stringify(v, null, 2) }); + } + if (uuid && viewCount > 0) { + viewsByApp[uuid] = (viewsByApp[uuid] || 0) + viewCount; + console.log(`📈 Views accumulated: ${uuid} = ${viewsByApp[uuid]} (added ${viewCount})`); + } }); - visits.forEach(v => { - const uuid = v.applicationUuid || v.uuid; - visitsByApp[uuid] = (visitsByApp[uuid] || 0) + v.visits; + visitsArray.forEach((v, index) => { + const uuid = v.applicationUuid; + const visitCount = v.visits || 0; + if (index < 3) { + console.log(`👥 Processing visits item ${index}:`, { uuid, visitCount, hasUuid: !!uuid, hasVisits: !!v.visits, item: JSON.stringify(v, null, 2) }); + } + if (uuid && visitCount > 0) { + visitsByApp[uuid] = (visitsByApp[uuid] || 0) + visitCount; + console.log(`👥 Visits accumulated: ${uuid} = ${visitsByApp[uuid]} (added ${visitCount})`); + } }); // Calculate totals const totalViews = Object.values(viewsByApp).reduce((sum, v) => sum + v, 0); const totalVisits = Object.values(visitsByApp).reduce((sum, v) => sum + v, 0); + console.log('📊 Aggregated views by app:', JSON.stringify(viewsByApp, null, 2)); + console.log('📊 Aggregated visits by app:', JSON.stringify(visitsByApp, null, 2)); + console.log('📊 Total views:', totalViews); + console.log('📊 Total visits:', totalVisits); + console.log('📊 Apps to process:', apps.map(a => `${a.name} (${a.uuid})`)); + // Merge stats - return apps.map(app => ({ + const result = apps.map(app => ({ ...app, views: viewsByApp[app.uuid] || 0, visits: visitsByApp[app.uuid] || 0, viewsPct: totalViews ? ((viewsByApp[app.uuid] || 0) / totalViews) * 100 : 0, visitsPct: totalVisits ? ((visitsByApp[app.uuid] || 0) / totalVisits) * 100 : 0, })); + + console.log('📈 Final stats for apps:', result.map(r => `${r.name}: ${r.views} views, ${r.visits} visits`)); + return result; } -export default async function ApplicationsPage() { - const { apps, views, visits, user } = await fetchData(); - const stats = getAppStats(apps, views, visits); +export default function ApplicationsPage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [applications, setApplications] = useState([]); + const [stats, setStats] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMetrics, setLoadingMetrics] = useState(false); + const [error, setError] = useState(null); + const [monthName, setMonthName] = useState(''); + const [loadTime, setLoadTime] = useState(null); + + // Check authentication and load applications first + useEffect(() => { + async function checkAuthAndLoadApps() { + try { + // Check authentication + const authResponse = await fetch('/api/auth/status'); + if (!authResponse.ok) { + router.push('/api/saml/login'); + return; + } + + const { authenticated, user: userData } = await authResponse.json(); + if (!authenticated) { + router.push('/api/saml/login'); + return; + } + + setUser(userData); + + // Get month name + const { monthName: currentMonthName } = getPreviousMonthRange(); + setMonthName(currentMonthName); + + // Load applications quickly first + const appsResponse = await fetch('/api/acquia/applications'); + if (!appsResponse.ok) { + throw new Error(`Failed to load applications: ${appsResponse.status}`); + } + + const appsData = await appsResponse.json(); + + // Filter out excluded UUIDs and apply user permissions + const excludedUuids = ['2b2d2517-3839-414e-85a4-7183adc22283', '1ef402a7-c301-42d7-9b63-f226fa1b2329']; + const filteredApps = appsData.filter((app: Application) => !excludedUuids.includes(app.uuid)); + + // Sort applications alphabetically by name + const sortedApps = filteredApps.sort((a: Application, b: Application) => + a.name.localeCompare(b.name) + ); + + setApplications(sortedApps); + + // Initialize stats with just application info (no metrics yet) + const initialStats: AppStats[] = sortedApps.map((app: Application) => ({ + ...app, + views: 0, + visits: 0, + viewsPct: 0, + visitsPct: 0, + })); + setStats(initialStats); + setLoading(false); + + // Now start loading the metrics data with timer + setLoadingMetrics(true); + await loadMetricsData(sortedApps); + + } catch (err) { + console.error('Error loading applications:', err); + setError(err instanceof Error ? err.message : 'Failed to load applications'); + setLoading(false); + } + } + + checkAuthAndLoadApps(); + }, [router]); + + async function loadMetricsData(apps: Application[]) { + const startTime = Date.now(); + + try { + const { from, to } = getPreviousMonthRange(); + const subscriptionUuid = process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID; + + // Fetch views and visits data with retry logic for 503 errors + const fetchWithRetry = async (url: string, retries = 3): Promise => { + for (let i = 0; i < retries; i++) { + const response = await fetch(url); + + if (response.status === 503) { + // Wait before retrying (exponential backoff) + await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)); + continue; + } + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + return response; + } + throw new Error('Service temporarily unavailable (503). Please try again later.'); + }; + + const [viewsResponse, visitsResponse] = await Promise.all([ + fetchWithRetry(`/api/acquia/views?subscriptionUuid=${subscriptionUuid}&from=${from}&to=${to}`), + fetchWithRetry(`/api/acquia/visits?subscriptionUuid=${subscriptionUuid}&from=${from}&to=${to}`) + ]); + + const [viewsData, visitsData] = await Promise.all([ + viewsResponse.json(), + visitsResponse.json() + ]); + + console.log('🌐 Raw views API response:', { + type: typeof viewsData, + isArray: Array.isArray(viewsData), + length: viewsData?.length, + keys: viewsData && typeof viewsData === 'object' ? Object.keys(viewsData) : 'N/A', + sample: viewsData + }); + + console.log('🌐 Raw visits API response:', { + type: typeof visitsData, + isArray: Array.isArray(visitsData), + length: visitsData?.length, + keys: visitsData && typeof visitsData === 'object' ? Object.keys(visitsData) : 'N/A', + sample: visitsData + }); + + // Handle different response formats - API routes wrap data in { data: [...] } + const viewsArray = Array.isArray(viewsData) ? viewsData : + Array.isArray(viewsData.data) ? viewsData.data : []; + + const visitsArray = Array.isArray(visitsData) ? visitsData : + Array.isArray(visitsData.data) ? visitsData.data : []; + + console.log('🔧 Extracted arrays:', { + viewsLength: viewsArray.length, + visitsLength: visitsArray.length + }); + + // Calculate final stats + const finalStats = getAppStats(apps, viewsArray, visitsArray); + setStats(finalStats); + + const endTime = Date.now(); + setLoadTime((endTime - startTime) / 1000); + setLoadingMetrics(false); + + } catch (err) { + console.error('Error loading metrics:', err); + setError(err instanceof Error ? err.message : 'Failed to load metrics data'); + setLoadingMetrics(false); + } + } + + if (loading) { + return ( +
+

Application Views & Visits

+
+
+

Loading applications...

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

Application Views & Visits ({monthName})

+
+

Error Loading Data

+

{error}

+ +
+
+ ); + } if (stats.length === 0) { return ( -
-

Application Views & Visits

-
-

No Applications Available

-

+

+

Application Views & Visits ({monthName})

+
+

No Applications Available

+

You don't have access to any applications. Contact your administrator if you believe this is an error.

-

+

Logged in as: {user?.sunetId || user?.email || 'Unknown user'}

@@ -92,32 +314,82 @@ export default async function ApplicationsPage() { } return ( -
-

Application Views & Visits

- - - - - - - - - - - - - {stats.map(app => ( - - - - - - - +
+

Application Views & Visits ({monthName})

+ + {loadingMetrics && ( +
+
+

Loading usage metrics...

+ +
+
+ )} + +
+
ApplicationUUIDViews% of ViewsVisits% of Visits
{app.name}{app.uuid}{app.views.toLocaleString()}{app.viewsPct.toFixed(1)}%{app.visits.toLocaleString()}{app.visitsPct.toFixed(1)}%
+ + + + + + + + - ))} - -
ApplicationUUIDViews% of ViewsVisits% of Visits
+ + + {stats.map(app => ( + + + + {app.name} + + + + + {app.uuid} + + + + {loadingMetrics ? ( + Loading... + ) : ( + app.views.toLocaleString() + )} + + + {loadingMetrics ? ( + + ) : ( + `${app.viewsPct.toFixed(1)}%` + )} + + + {loadingMetrics ? ( + Loading... + ) : ( + app.visits.toLocaleString() + )} + + + {loadingMetrics ? ( + + ) : ( + `${app.visitsPct.toFixed(1)}%` + )} + + + ))} + + +
); } \ No newline at end of file