// Zoho OAuth2 Authentication Module // EU data center: accounts.zoho.eu var ACCOUNTS_BASE = 'https://accounts.zoho.eu'; var SCOPE = 'ZohoProjects.tasks.ALL,ZohoProjects.projects.READ,ZohoProjects.tasklists.READ,ZohoProjects.timesheets.ALL,ZohoProjects.statuses.READ,ZohoProjects.tasklists.CREATE,ZohoProjects.tasklists.UPDATE,ZohoProjects.portals.READ'; function setupAuth(grantToken) { var clientId = shadowman.config.value('client_id'); var clientSecret = shadowman.config.value('client_secret'); if (!clientId || !clientSecret) { return { error: 'Missing OAuth credentials. Set client_id and client_secret in plugin config.' }; } if (!grantToken) { return { error: 'Missing grant_token parameter. Generate one from Zoho Developer Console (Self Client).' }; } shadowman.log.info('Exchanging Zoho grant token for access + refresh tokens...'); var body = 'grant_type=authorization_code' + '&client_id=' + encodeURIComponent(clientId) + '&client_secret=' + encodeURIComponent(clientSecret) + '&redirect_uri=oob' + '&code=' + encodeURIComponent(grantToken); var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); if (resp.status !== 200) { shadowman.log.error('Token exchange failed: ' + resp.status + ' ' + resp.body); var errBody; try { errBody = JSON.parse(resp.body); } catch (e) { errBody = resp.body; } return { error: 'Token exchange failed: ' + (errBody.error || errBody.error_description || resp.body) }; } var tokens; try { tokens = JSON.parse(resp.body); } catch (e) { return { error: 'Invalid response from Zoho: ' + resp.body }; } shadowman.storage.set('tokens', { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: Date.now() + (tokens.expires_in * 1000), api_domain: tokens.api_domain || 'https://projectsapi.zoho.eu', token_type: tokens.token_type || 'Bearer' }); shadowman.log.info('Zoho OAuth tokens stored successfully. Refresh token does not expire.'); // Auto-resolve portal if portal_url config is set var portalUrl = shadowman.config.value('portal_url'); var resolveResult = null; if (portalUrl) { resolveResult = resolvePortalInternal(tokens.access_token, portalUrl, null); } var result = { success: true, message: 'Authentication successful. Refresh token stored permanently.', expires_in: tokens.expires_in }; if (resolveResult && resolveResult.success) { result.portal_id = resolveResult.portal_id; result.portal_name = resolveResult.portal_name; result.message += ' Portal auto-resolved: ' + resolveResult.portal_name + ' (ID: ' + resolveResult.portal_id + ')'; } else if (portalUrl) { result.message += ' Portal auto-resolution failed. Run resolve_portal manually.'; } else { result.message += ' Run resolve_portal with your portal URL to auto-configure.'; } // Notify conversation var convId = shadowman.storage.get('auth_conversation_id'); if (convId) { var msg = 'Zoho OAuth sikeres!'; if (resolveResult && resolveResult.success) { msg += ' Portal automatikusan beállítva: ' + resolveResult.portal_name; } else { msg += ' Futtasd a resolve_portal parancsot a portal URL-eddel.'; } shadowman.events.emit('message', { text: msg, conversationId: convId }); shadowman.storage.del('auth_conversation_id'); } return result; } function resolvePortalInternal(token, portalUrl, portalName) { var apiBase = 'https://projectsapi.zoho.eu'; // Method 1: /api/v3/portals — returns all portals with numeric ID, name, slug var resp = shadowman.http.request(apiBase + '/api/v3/portals', { method: 'GET', headers: { 'Authorization': 'Zoho-oauthtoken ' + token } }); if (resp.status === 200) return processPortalsResponse(resp.body, portalUrl, portalName); // Method 2: Fallback — use v1 API with slug, extract numeric ID from response var slug = portalUrl ? extractSlug(portalUrl) : ''; if (!slug) { var existingId = shadowman.config.value('portal_id'); if (existingId) { return { success: true, portal_id: existingId, note: 'Using existing portal_id. Re-run setup_auth with ZohoProjects.portals.READ scope to enable /api/v3/portals.' }; } return { error: 'Portal URL not provided and /api/v3/portals failed (scope?). Run setup_auth again with a fresh grant token.' }; } var testResp = shadowman.http.request(apiBase + '/restapi/portal/' + slug + '/projects/', { method: 'GET', headers: { 'Authorization': 'Zoho-oauthtoken ' + token } }); if (testResp.status === 200) { try { var testData = JSON.parse(testResp.body); var projects = testData.projects || []; if (projects.length > 0) { var url = (projects[0].link || {}).self || {}; var urlStr = url.url || ''; var urlMatch = urlStr.match(/\/portal\/(\d+)\//); if (urlMatch) { var numericId = urlMatch[1]; shadowman.config.set('portal_id', numericId); shadowman.log.info('Portal resolved (v1 fallback): ' + slug + ' -> ID: ' + numericId); return { success: true, portal_id: numericId, portal_slug: slug }; } } } catch (e) { /* ignore */ } shadowman.config.set('portal_id', slug); shadowman.log.info('Portal configured with slug (no numeric ID found): ' + slug); return { success: true, portal_id: slug, portal_slug: slug, note: 'Using slug. Timesheet API may require numeric ID.' }; } return { error: 'Portal "' + slug + '" not found. Check the URL and your access.' }; } function processPortalsResponse(body, portalUrl, portalName) { var data; try { data = JSON.parse(body); } catch (e) { return { error: 'Invalid portal list response: ' + body }; } var portals = data.portals || []; if (portals.length === 0) { return { error: 'No portals found for this account.', portals: [] }; } if (portals.length === 1 && !portalUrl && !portalName) { var p = portals[0]; shadowman.config.set('portal_id', String(p.id)); shadowman.log.info('Portal auto-configured: ' + p.name + ' (ID: ' + p.id + ')'); return { success: true, portal_id: String(p.id), portal_name: p.name, portal_slug: p.portal_slug }; } var match = null; if (portalUrl) { var slug = extractSlug(portalUrl); for (var i = 0; i < portals.length; i++) { if (portals[i].portal_slug && portals[i].portal_slug.toLowerCase() === slug.toLowerCase()) { match = portals[i]; break; } if (portals[i].name && portals[i].name.toLowerCase() === slug.toLowerCase()) { match = portals[i]; break; } } } else if (portalName) { for (var j = 0; j < portals.length; j++) { if (portals[j].name && portals[j].name.toLowerCase() === portalName.toLowerCase()) { match = portals[j]; break; } } } if (match) { shadowman.config.set('portal_id', String(match.id)); shadowman.log.info('Portal configured: ' + match.name + ' (ID: ' + match.id + ')'); return { success: true, portal_id: String(match.id), portal_name: match.name, portal_slug: match.portal_slug }; } var list = []; for (var k = 0; k < portals.length; k++) { list.push({ id: String(portals[k].id), name: portals[k].name, slug: portals[k].portal_slug }); } return { error: 'Portal not found. Available portals:', portals: list }; } function extractSlug(url) { if (!url) return ''; var slug = url; var slashIdx = slug.lastIndexOf('/'); if (slashIdx !== -1) slug = slug.substring(slashIdx + 1); if (slug === 'portal' || slug === '') return ''; return slug; } function resolvePortal(token, args) { return resolvePortalInternal(token, args.portal_url || null, args.portal_name || null); } function getStatus() { var tokens = shadowman.storage.get('tokens'); if (!tokens) { return { authenticated: false, message: 'Not authenticated. Use setup_auth with a grant token.' }; } if (Date.now() >= tokens.expires_at) { // Token expired, try refresh var newTokens = refreshTokenInternal(); if (newTokens.error) { return { authenticated: false, message: 'Token expired and refresh failed. Re-run setup_auth.' }; } return { authenticated: true, message: 'Authenticated (token refreshed)', domain: newTokens.api_domain }; } var remaining = Math.round((tokens.expires_at - Date.now()) / 1000); return { authenticated: true, message: 'Authenticated', expires_in_seconds: remaining, domain: tokens.api_domain }; } function getValidToken() { var tokens = shadowman.storage.get('tokens'); if (!tokens) { return null; } // Check if token needs refresh (5 minute buffer) if (Date.now() >= (tokens.expires_at - 300000)) { var newTokens = refreshTokenInternal(); if (newTokens.error) { shadowman.log.error('Token refresh failed: ' + newTokens.error); // Refresh failed but token might still be valid if (Date.now() < tokens.expires_at) { return tokens.access_token; } return null; } return newTokens.access_token; } return tokens.access_token; } function refreshTokenInternal() { var tokens = shadowman.storage.get('tokens'); if (!tokens || !tokens.refresh_token) { return { error: 'No refresh token available' }; } var clientId = shadowman.config.value('client_id'); var clientSecret = shadowman.config.value('client_secret'); if (!clientId || !clientSecret) { return { error: 'OAuth client credentials missing' }; } shadowman.log.info('Refreshing Zoho access token...'); var body = 'grant_type=refresh_token' + '&client_id=' + encodeURIComponent(clientId) + '&client_secret=' + encodeURIComponent(clientSecret) + '&refresh_token=' + encodeURIComponent(tokens.refresh_token); var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); if (resp.status !== 200) { shadowman.log.error('Token refresh failed: ' + resp.status + ' ' + resp.body); return { error: 'Refresh failed: ' + resp.body }; } var newTokens; try { newTokens = JSON.parse(resp.body); } catch (e) { return { error: 'Invalid refresh response: ' + resp.body }; } var updated = { access_token: newTokens.access_token, refresh_token: tokens.refresh_token, // Keep original - Zoho refresh tokens don't expire expires_at: Date.now() + (newTokens.expires_in * 1000), api_domain: newTokens.api_domain || tokens.api_domain, token_type: newTokens.token_type || 'Bearer' }; shadowman.storage.set('tokens', updated); shadowman.log.info('Zoho token refreshed successfully'); return updated; } module.exports = { setupAuth: setupAuth, getStatus: getStatus, getValidToken: getValidToken, resolvePortal: resolvePortal };