diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index f5c8d7b617..6e65cefb9f 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -129,8 +129,12 @@ export async function POST(request: NextRequest) { { status: 200 } ) } catch (error) { - logger.error(`[${requestId}] Failed to refresh access token:`, error) - return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 }) + const errorMessage = error instanceof Error ? error.message : 'Failed to refresh access token' + logger.error(`[${requestId}] Failed to refresh access token:`, { + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + }) + return NextResponse.json({ error: errorMessage }, { status: 401 }) } } catch (error) { logger.error(`[${requestId}] Error getting access token`, error) @@ -207,8 +211,13 @@ export async function GET(request: NextRequest) { }, { status: 200 } ) - } catch (_error) { - return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to refresh access token' + logger.error(`[${requestId}] Failed to refresh access token:`, { + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + }) + return NextResponse.json({ error: errorMessage }, { status: 401 }) } } catch (error) { logger.error(`[${requestId}] Error fetching access token`, error) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 08dd16fdff..6ed65298b7 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -135,11 +135,14 @@ export async function getOAuthToken(userId: string, providerId: string): Promise const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!) if (!refreshResult) { - logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, { - providerId, - userId, - hasRefreshToken: !!credential.refreshToken, - }) + logger.error( + `Failed to refresh token for user ${userId}, provider ${providerId} - no result returned`, + { + providerId, + userId, + hasRefreshToken: !!credential.refreshToken, + } + ) return null } @@ -170,7 +173,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise providerId, userId, }) - return null + throw error } } @@ -221,12 +224,15 @@ export async function refreshAccessTokenIfNeeded( ) if (!refreshedToken) { - logger.error(`[${requestId}] Failed to refresh token for credential: ${credentialId}`, { - credentialId, - providerId: credential.providerId, - userId: credential.userId, - hasRefreshToken: !!credential.refreshToken, - }) + logger.error( + `[${requestId}] Failed to refresh token for credential: ${credentialId} - no result returned`, + { + credentialId, + providerId: credential.providerId, + userId: credential.userId, + hasRefreshToken: !!credential.refreshToken, + } + ) return null } @@ -249,6 +255,7 @@ export async function refreshAccessTokenIfNeeded( logger.info(`[${requestId}] Successfully refreshed access token for credential`) return refreshedToken.accessToken } catch (error) { + // Re-throw the error to propagate detailed error messages (e.g., session expiry instructions) logger.error(`[${requestId}] Error refreshing token for credential`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, @@ -256,7 +263,7 @@ export async function refreshAccessTokenIfNeeded( credentialId, userId: credential.userId, }) - return null + throw error } } else if (!accessToken) { // We have no access token and either no refresh token or not eligible to refresh @@ -292,8 +299,8 @@ export async function refreshTokenIfNeeded( const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!) if (!refreshResult) { - logger.error(`[${requestId}] Failed to refresh token for credential`) - throw new Error('Failed to refresh token') + logger.error(`[${requestId}] Failed to refresh token for credential - no result returned`) + throw new Error('Failed to refresh token: no result returned from provider') } const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult diff --git a/apps/sim/blocks/blocks/google_vault.ts b/apps/sim/blocks/blocks/google_vault.ts index c54098aad3..3de897bb16 100644 --- a/apps/sim/blocks/blocks/google_vault.ts +++ b/apps/sim/blocks/blocks/google_vault.ts @@ -159,6 +159,90 @@ Return ONLY the hold name - no explanations, no quotes, no extra text.`, placeholder: 'Org Unit ID (alternative to emails)', condition: { field: 'operation', value: ['create_matters_holds', 'create_matters_export'] }, }, + // Date filtering for exports and holds (holds only support MAIL and GROUPS corpus) + { + id: 'startTime', + title: 'Start Time', + type: 'short-input', + placeholder: 'YYYY-MM-DDTHH:mm:ssZ', + condition: { field: 'operation', value: ['create_matters_export', 'create_matters_holds'] }, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp in GMT based on the user's description for Google Vault date filtering. +The timestamp should be in the format: YYYY-MM-DDTHH:mm:ssZ (UTC timezone). +Note: Google Vault rounds times to 12 AM on the specified date. +Examples: +- "yesterday" -> Calculate yesterday's date at 00:00:00Z +- "last week" -> Calculate 7 days ago at 00:00:00Z +- "beginning of this month" -> Calculate the 1st of current month at 00:00:00Z +- "January 1, 2024" -> 2024-01-01T00:00:00Z + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the start date (e.g., "last month", "January 1, 2024")...', + generationType: 'timestamp', + }, + }, + { + id: 'endTime', + title: 'End Time', + type: 'short-input', + placeholder: 'YYYY-MM-DDTHH:mm:ssZ', + condition: { field: 'operation', value: ['create_matters_export', 'create_matters_holds'] }, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp in GMT based on the user's description for Google Vault date filtering. +The timestamp should be in the format: YYYY-MM-DDTHH:mm:ssZ (UTC timezone). +Note: Google Vault rounds times to 12 AM on the specified date. +Examples: +- "now" -> Current timestamp +- "today" -> Today's date at 23:59:59Z +- "end of last month" -> Last day of previous month at 23:59:59Z +- "December 31, 2024" -> 2024-12-31T23:59:59Z + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the end date (e.g., "today", "end of last quarter")...', + generationType: 'timestamp', + }, + }, + { + id: 'terms', + title: 'Search Terms', + type: 'long-input', + placeholder: 'Enter search query (e.g., from:user@example.com subject:confidential)', + condition: { field: 'operation', value: ['create_matters_export', 'create_matters_holds'] }, + wandConfig: { + enabled: true, + prompt: `Generate a Google Vault search query based on the user's description. +The query can use Gmail-style search operators for MAIL corpus: +- from:user@example.com - emails from specific sender +- to:user@example.com - emails to specific recipient +- subject:keyword - emails with keyword in subject +- has:attachment - emails with attachments +- filename:pdf - emails with PDF attachments +- before:YYYY/MM/DD - emails before date +- after:YYYY/MM/DD - emails after date + +For DRIVE corpus, use Drive search operators: +- owner:user@example.com - files owned by user +- type:document - specific file types + +For holds, date filtering only works with MAIL and GROUPS corpus. + +Return ONLY the search query - no explanations, no quotes, no extra text.`, + placeholder: 'Describe what content to search for...', + }, + }, + // Drive-specific option for holds + { + id: 'includeSharedDrives', + title: 'Include Shared Drives', + type: 'switch', + condition: { + field: 'operation', + value: 'create_matters_holds', + and: { field: 'corpus', value: 'DRIVE' }, + }, + }, { id: 'exportId', title: 'Export ID', @@ -296,9 +380,16 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, corpus: { type: 'string', description: 'Data corpus (MAIL, DRIVE, GROUPS, etc.)' }, accountEmails: { type: 'string', description: 'Comma-separated account emails' }, orgUnitId: { type: 'string', description: 'Organization unit ID' }, + startTime: { type: 'string', description: 'Start time for date filtering (ISO 8601 format)' }, + endTime: { type: 'string', description: 'End time for date filtering (ISO 8601 format)' }, + terms: { type: 'string', description: 'Search query terms' }, // Create hold inputs holdName: { type: 'string', description: 'Name for the hold' }, + includeSharedDrives: { + type: 'boolean', + description: 'Include files in shared drives (for DRIVE corpus holds)', + }, // Download export file inputs bucketName: { type: 'string', description: 'GCS bucket name from export' }, diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 94496a24b0..a78e2935c5 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1171,7 +1171,7 @@ export async function refreshOAuthToken( if (!response.ok) { const errorText = await response.text() - let errorData = errorText + let errorData: any = errorText try { errorData = JSON.parse(errorText) @@ -1191,6 +1191,29 @@ export async function refreshOAuthToken( hasRefreshToken: !!refreshToken, refreshTokenPrefix: refreshToken ? `${refreshToken.substring(0, 10)}...` : 'none', }) + + // Check for Google Workspace session control errors (RAPT - Reauthentication Policy Token) + // This occurs when the organization enforces periodic re-authentication + if ( + typeof errorData === 'object' && + (errorData.error_subtype === 'invalid_rapt' || + errorData.error_description?.includes('reauth related error')) + ) { + throw new Error( + `Session expired due to organization security policy. Please reconnect your ${providerId} account to continue. Alternatively, ask your Google Workspace admin to exempt this app from session control: Admin Console → Security → Google Cloud session control → "Exempt trusted apps".` + ) + } + + if ( + typeof errorData === 'object' && + errorData.error === 'invalid_grant' && + !errorData.error_subtype + ) { + throw new Error( + `Access has been revoked or the refresh token is no longer valid. Please reconnect your ${providerId} account.` + ) + } + throw new Error(`Failed to refresh token: ${response.status} ${errorText}`) } @@ -1224,6 +1247,8 @@ export async function refreshOAuthToken( } } catch (error) { logger.error('Error refreshing token:', { error }) - return null + // Re-throw specific errors so they propagate with their detailed messages + // Only return null for truly unexpected errors without useful messages + throw error } } diff --git a/apps/sim/tools/google_vault/create_matters_export.ts b/apps/sim/tools/google_vault/create_matters_export.ts index f468fc7ab7..3f443ce6d6 100644 --- a/apps/sim/tools/google_vault/create_matters_export.ts +++ b/apps/sim/tools/google_vault/create_matters_export.ts @@ -36,6 +36,24 @@ export const createMattersExportTool: ToolConfig