import { createRouter } from '../../create-router'; import { FORBIDDEN, BAD_REQUEST } from '../../custom-errors'; import { getServerIntegration, upsertServerIntegration, getUserByEmail, getUserIntegration, upsertUserIntegration, deleteUserIntegration, getDockPaths, setDockPaths, } from 'officerdb'; import { getValidGoogleAccessToken } from './google-auth'; const GOOGLE_SCOPES = [ 'https://mail.google.com/', 'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/userinfo.email', ]; type GoogleConfig = { clientId: string; clientSecret: string }; export const readGoogleConfig = async (): Promise => { const integration = await getServerIntegration('google'); if (!integration) return null; const config = integration.config as Record; if (!config.clientId || !config.clientSecret) return null; return config as unknown as GoogleConfig; }; export const integrationsRouter = createRouter(); integrationsRouter.get('/', async (ctx) => { return ctx.json([]); }); // --- Apify config --- type ApifyConfig = { apiToken: string }; export const readApifyConfig = async (): Promise => { const integration = await getServerIntegration('apify'); if (!integration) return null; const config = integration.config as Record; if (!config.apiToken) return null; return config as unknown as ApifyConfig; }; integrationsRouter.get('/apify/config', async (ctx) => { return ctx.json(await readApifyConfig()); }); integrationsRouter.put('/apify/config', async (ctx) => { const body = ctx.get('body') as { apiToken?: string }; const config = { apiToken: body.apiToken ?? '' }; await upsertServerIntegration('apify', config); return ctx.json(config); }); integrationsRouter.get('/apify/status', async (ctx) => { const config = await readApifyConfig(); return ctx.json({ configured: !!config?.apiToken }); }); // --- Google OAuth config --- integrationsRouter.get('/google/config', async (ctx) => { return ctx.json(await readGoogleConfig()); }); integrationsRouter.put('/google/config', async (ctx) => { const body = ctx.get('body') as { clientId?: string; clientSecret?: string }; const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' }; await upsertServerIntegration('google', config); return ctx.json(config); }); integrationsRouter.get('/google/verify', async (ctx) => { const config = await readGoogleConfig(); if (!config?.clientId || !config?.clientSecret) { return ctx.json({ valid: false, error: 'Missing credentials' }); } const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: config.clientId, client_secret: config.clientSecret, code: 'invalid_code', redirect_uri: 'https://localhost', grant_type: 'authorization_code', }), }); const body = await res.json(); const valid = body.error === 'invalid_grant' || body.error === 'redirect_uri_mismatch'; return ctx.json({ valid, error: valid ? null : (body.error_description ?? body.error) }); }); // --- Personal: Google account connection status --- integrationsRouter.get('/google/status', async (ctx) => { const user = ctx.get('user'); const config = await readGoogleConfig(); const connection = await getUserIntegration(user.id, 'google'); const connConfig = connection?.config as Record | undefined; return ctx.json({ configured: !!(config?.clientId && config?.clientSecret), connected: !!connConfig?.accessToken, email: connConfig?.email ?? null, picture: connConfig?.picture ?? null, hasAppPassword: !!connConfig?.imapAppPassword, }); }); integrationsRouter.put('/google/app-password', async (ctx) => { const user = ctx.get('user'); const body = ctx.get('body') as { appPassword?: string; email?: string }; if (!body.appPassword) throw BAD_REQUEST('Missing appPassword'); const connection = await getUserIntegration(user.id, 'google'); const connConfig = (connection?.config as Record) ?? {}; const updated: Record = { ...connConfig, imapAppPassword: body.appPassword }; if (body.email) updated.email = body.email; await upsertUserIntegration({ userId: user.id, provider: 'google', serverIntegrationId: connection?.serverIntegrationId ?? undefined, config: updated, }); return ctx.json({ ok: true }); }); integrationsRouter.delete('/google/connection', async (ctx) => { const user = ctx.get('user'); const connection = await getUserIntegration(user.id, 'google'); const connConfig = connection?.config as Record | undefined; // Revoke token at Google so the old grant is fully removed const token = (connConfig?.refreshToken as string) || (connConfig?.accessToken as string); if (token) { fetch(`https://oauth2.googleapis.com/revoke?token=${token}`, { method: 'POST' }).catch(() => {}); } await deleteUserIntegration(user.id, 'google'); return ctx.json({ ok: true }); }); // --- Gmail proxy: forwards arbitrary Gmail REST calls with auto-refreshed OAuth --- type GmailProxyBody = { method?: string; path?: string; body?: unknown }; const GMAIL_ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); integrationsRouter.post('/google/gmail-proxy', async (ctx) => { const user = ctx.get('user'); const body = ctx.get('body') as GmailProxyBody; const method = (body.method ?? '').toUpperCase(); const path = body.path ?? ''; if (!GMAIL_ALLOWED_METHODS.has(method)) throw BAD_REQUEST('Invalid method'); if (!path.startsWith('/')) throw BAD_REQUEST('path must start with /'); const accessToken = await getValidGoogleAccessToken(user.id); if (!accessToken) throw BAD_REQUEST('No Google account connected — connect in Settings → Integrations'); const url = `https://gmail.googleapis.com/gmail/v1${path}`; const init: RequestInit = { method, headers: { Authorization: `Bearer ${accessToken}` }, }; if (body.body !== undefined && method !== 'GET' && method !== 'DELETE') { (init.headers as Record)['Content-Type'] = 'application/json'; init.body = JSON.stringify(body.body); } const upstream = await fetch(url, init); const text = await upstream.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { parsed = text; } return ctx.json({ status: upstream.status, ok: upstream.ok, body: parsed }); }); // --- OAuth flow: authorize (protected — user must be logged in) --- integrationsRouter.get('/google/authorize', async (ctx) => { const config = await readGoogleConfig(); if (!config?.clientId || !config?.clientSecret) { throw BAD_REQUEST('Google OAuth not configured'); } const email = ctx.get('user').email; const origin = ctx.req.query('origin'); if (!origin) throw BAD_REQUEST('Missing origin parameter'); const redirectUri = `${origin}/api/integrations/google/callback`; const state = Buffer.from(JSON.stringify({ email, redirectUri })).toString('base64url'); const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: redirectUri, response_type: 'code', scope: GOOGLE_SCOPES.join(' '), access_type: 'offline', prompt: 'consent', include_granted_scopes: 'false', state, }); return ctx.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`); }); // --- OAuth callback (public — called by Google, exported for hono.ts) --- export const googleCallbackHandler = async (ctx: any) => { const code = ctx.req.query('code'); const stateParam = ctx.req.query('state'); const error = ctx.req.query('error'); if (error || !code || !stateParam) { return ctx.redirect('/settings/integrations?google=error'); } let email: string; let redirectUri: string; try { const parsed = JSON.parse(Buffer.from(stateParam, 'base64url').toString()); email = parsed.email; redirectUri = parsed.redirectUri; } catch { return ctx.redirect('/settings/integrations?google=error'); } const config = await readGoogleConfig(); if (!config?.clientId || !config?.clientSecret) { return ctx.redirect('/settings/integrations?google=error'); } const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, client_id: config.clientId, client_secret: config.clientSecret, redirect_uri: redirectUri, grant_type: 'authorization_code', }), }); if (!tokenResponse.ok) { console.error('Google token exchange failed:', await tokenResponse.text()); return ctx.redirect('/settings/integrations?google=error'); } const tokens = await tokenResponse.json(); const userinfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${tokens.access_token}` }, }); let googleEmail = email; let picture: string | null = null; if (userinfoResponse.ok) { const userinfo = await userinfoResponse.json(); googleEmail = userinfo.email ?? email; picture = userinfo.picture ?? null; } const dbUser = await getUserByEmail(email); if (!dbUser) { return ctx.redirect('/settings/integrations?google=error'); } const serverIntegration = await getServerIntegration('google'); // Merge with existing config to preserve fields like imapAppPassword const existingIntegration = await getUserIntegration(dbUser.id, 'google'); const existingConfig = (existingIntegration?.config as Record) ?? {}; await upsertUserIntegration({ userId: dbUser.id, provider: 'google', serverIntegrationId: serverIntegration?.id, config: { ...existingConfig, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + tokens.expires_in * 1000, email: googleEmail, picture, scope: tokens.scope, }, }); // Auto-add /email to dock try { const existing = await getDockPaths(dbUser.id); const paths = existing ?? ['/', '/files', '/automation', '/dashboards', '/chat']; if (!paths.includes('/email')) { paths.push('/email'); await setDockPaths(dbUser.id, paths); } } catch { // Non-critical — don't block the OAuth flow } return ctx.redirect('/settings/integrations?google=success'); };