From 2d60936407f0644394749f79fb17e67c6bda02d7 Mon Sep 17 00:00:00 2001 From: EstoesMoises Date: Tue, 13 May 2025 18:10:36 +0100 Subject: [PATCH 1/7] Added support for Basic and Business tiers --- app-config.yaml | 11 +- .../StackOverflowQuestionsCollatorFactory.ts | 16 +- .../stack-overflow-teams-backend/config.d.ts | 8 +- .../stack-overflow-teams-backend/package.json | 1 + .../src/api/createStackOverflowApi.ts | 2 +- .../src/plugin.ts | 4 +- .../src/router.ts | 59 ++++- .../createStackOverflowService.ts | 2 +- .../services/StackOverflowService/types.ts | 6 +- plugins/stack-overflow-teams/package.json | 1 + .../src/api/StackOverflowAPI.ts | 11 +- .../StackOverflowAuth/StackAuthStart.tsx | 243 +++++++++++++++--- yarn.lock | 45 +++- 13 files changed, 352 insertions(+), 57 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index a222bd2..65c0855 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -6,17 +6,20 @@ organization: name: My Company stackoverflow: - baseUrl: ${STACK_OVERFLOW_INSTANCE_URL} - # teamName: ${STACK_OVERFLOW_TEAM_NAME} + # baseUrl: ${STACK_OVERFLOW_INSTANCE_URL} + # Required only for Enteprise Tier. + + teamName: ${STACK_OVERFLOW_TEAM_NAME} + # Required only for Basic and Business Tiers. apiAccessToken: ${STACK_OVERFLOW_API_ACCESS_TOKEN} # The API Access Token is used for the Questions' collator, a no-expiry, read-only token is recommended. clientId: ${STACK_OVERFLOW_CLIENT_ID} - # The clientid must be for an API Application with read-write access. + # The clientid must be for an API Application with read-write access. Only provide if you are using the Enterprise tier. redirectUri: ${STACK_OVERFLOW_REDIRECT_URI} - # If no redirectUri is specified this will return to https:///stack-overflow-teams + # If no redirectUri is specified this will return to https:///stack-overflow-teams. Only provide if you are using the Enterprise tier. backend: # Used for enabling authentication, secret is shared by all backend plugins diff --git a/plugins/search-backend-module-stack-overflow-teams-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts b/plugins/search-backend-module-stack-overflow-teams-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts index 2983452..f7995aa 100644 --- a/plugins/search-backend-module-stack-overflow-teams-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts +++ b/plugins/search-backend-module-stack-overflow-teams-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts @@ -89,8 +89,8 @@ export class StackOverflowQuestionsCollatorFactory private forceOriginUrl = (baseUrl: string): string => `${new URL(baseUrl).origin}`; - private constructor(options: StackOverflowQuestionsCollatorFactoryOptions & { baseUrl: string }) { - this.baseUrl = this.forceOriginUrl(options.baseUrl); + private constructor(options: StackOverflowQuestionsCollatorFactoryOptions & { baseUrl?: string }) { + this.baseUrl = this.forceOriginUrl(options.baseUrl || this.stackOverflowTeamsAPI); this.apiAccessToken = options.apiAccessToken; this.teamName = options.teamName; this.logger = options.logger.child({ documentType: this.type }); @@ -110,7 +110,7 @@ export class StackOverflowQuestionsCollatorFactory ) { const apiAccessToken = config.getString('stackoverflow.apiAccessToken'); const teamName = config.getOptionalString('stackoverflow.teamName'); - const baseUrl = config.getString('stackoverflow.baseUrl'); + const baseUrl = config.getOptionalString('stackoverflow.baseUrl'); const requestParams = config .getOptionalConfig('stackoverflow.requestParams') ?.get(); @@ -134,11 +134,17 @@ export class StackOverflowQuestionsCollatorFactory this.logger.info(`Retrieving data using Stack Overflow API Version 3`); if (!this.baseUrl) { - this.logger.error( - `No stackoverflow.baseUrl configured in your app-config.yaml`, + this.logger.warn( + `No stackoverflow.baseUrl configured in your app-config.yaml defaulting to https://api.stackoverflowteams.com`, ); } + if (!this.baseUrl && !this.teamName) { + this.logger.error( + `No stackoverflow.teamName has been provided while trying to connect to the Teams API.` + ) + } + const params = qs.stringify(this.requestParams, { arrayFormat: 'comma', addQueryPrefix: true, diff --git a/plugins/stack-overflow-teams-backend/config.d.ts b/plugins/stack-overflow-teams-backend/config.d.ts index 563b1bc..4beadd4 100644 --- a/plugins/stack-overflow-teams-backend/config.d.ts +++ b/plugins/stack-overflow-teams-backend/config.d.ts @@ -33,17 +33,17 @@ export interface Config { apiAccessToken: string; /** - * The name of the team for a Stack Overflow for Teams account + * The name of the team for a Stack Overflow for Teams account, required for Basic and Business tiers. */ teamName?: string; /** - * Client Id for the OAuth Application, required to use the Stack Overflow for Teams Hub and write actions. + * Client Id for the OAuth Application, required only for Stack Overflow Enterprise and write actions. */ - clientId: number; + clientId?: number; /** - * RedirectUri for the OAuth Application, required to use the Stack Overflow for Teams Hub and write actions. + * RedirectUri for the OAuth Application, required only for Stack Overflow Enterprise and write actions. * * This should be your Backstage application domain ending in the plugin's route * If not specified this will got to your /stack-overflow-teams diff --git a/plugins/stack-overflow-teams-backend/package.json b/plugins/stack-overflow-teams-backend/package.json index a36a41b..7365596 100644 --- a/plugins/stack-overflow-teams-backend/package.json +++ b/plugins/stack-overflow-teams-backend/package.json @@ -40,6 +40,7 @@ "@backstage/plugin-catalog-node": "^1.15.0", "@backstage/plugin-search-backend-node": "^1.3.8", "@backstage/plugin-search-common": "^1.2.17", + "csrf": "^3.1.0", "express": "^4.17.1", "express-promise-router": "^4.1.0", "jsonwebtoken": "^9.0.2", diff --git a/plugins/stack-overflow-teams-backend/src/api/createStackOverflowApi.ts b/plugins/stack-overflow-teams-backend/src/api/createStackOverflowApi.ts index ca1b010..816cc47 100644 --- a/plugins/stack-overflow-teams-backend/src/api/createStackOverflowApi.ts +++ b/plugins/stack-overflow-teams-backend/src/api/createStackOverflowApi.ts @@ -9,7 +9,7 @@ export const createStackOverflowApi = (baseUrl: string) => { pageSize?: number ): Promise => { let url = teamName - ? `${baseUrl}/api/v3/teams/${teamName}${endpoint}` + ? `${baseUrl}/v3/teams/${teamName}${endpoint}` : `${baseUrl}/api/v3${endpoint}`; const queryParams = new URLSearchParams(); diff --git a/plugins/stack-overflow-teams-backend/src/plugin.ts b/plugins/stack-overflow-teams-backend/src/plugin.ts index 90782dc..5747464 100644 --- a/plugins/stack-overflow-teams-backend/src/plugin.ts +++ b/plugins/stack-overflow-teams-backend/src/plugin.ts @@ -24,9 +24,9 @@ export const stackOverflowTeamsPlugin = createBackendPlugin({ async init({ logger, httpRouter, config }) { const forceOriginUrl = (baseUrl: string) : string => `${new URL(baseUrl).origin}` const stackOverflowConfig: StackOverflowConfig = { - baseUrl: forceOriginUrl(config.getString('stackoverflow.baseUrl')), + baseUrl: forceOriginUrl(config.getOptionalString('stackoverflow.baseUrl') || 'https://api.stackoverflowteams.com'), teamName: config.getOptionalString('stackoverflow.teamName'), - clientId: config.getNumber('stackoverflow.clientId'), + clientId: config.getOptionalNumber('stackoverflow.clientId'), redirectUri: config.getOptionalString('stackoverflow.redirectUri') || `${config.getString('app.baseUrl')}/stack-overflow-teams` }; const stackOverflowService = await createStackOverflowService({ diff --git a/plugins/stack-overflow-teams-backend/src/router.ts b/plugins/stack-overflow-teams-backend/src/router.ts index 794271f..ab360b9 100644 --- a/plugins/stack-overflow-teams-backend/src/router.ts +++ b/plugins/stack-overflow-teams-backend/src/router.ts @@ -33,7 +33,6 @@ export async function createRouter({ ); } - // Never returning SO Tokens to the frontend. function getValidAuthToken(req: Request, res: Response): string | null { const cookies = cookieParse(req); const cookiesToken = cookies['stackoverflow-access-token']; @@ -123,9 +122,15 @@ export async function createRouter({ .json({ error: 'Missing Stack Overflow Teams Access Token' }); } - const baseUrl = authService.config.baseUrl; + const baseUrl = stackOverflowConfig.baseUrl; + const teamName = stackOverflowConfig.teamName; + + // Use the team-specific API endpoint for basic and business teams + const userApiUrl = teamName + ? `${baseUrl}/v3/teams/${teamName}/users/me` + : `${baseUrl}/api/v3/users/me`; - const userResponse = await fetch(`${baseUrl}/api/v3/users/me`, { + const userResponse = await fetch(userApiUrl, { headers: { Authorization: `Bearer ${authToken}` }, }); @@ -149,6 +154,52 @@ export async function createRouter({ } }); + // PAT Input Route (basic and business support) + + router.post('/auth/token', async (req: Request, res: Response) => { + try { + const { accessToken } = req.body; + + if (!accessToken || typeof accessToken !== 'string') { + return res.status(400).json({ error: 'Valid access token is required' }); + } + + // Don't use authService here, use the config directly + const baseUrl = stackOverflowConfig.baseUrl; + const teamName = stackOverflowConfig.teamName; + + // Use the team-specific API endpoint for basic and business teams + const validationUrl = teamName + ? `${baseUrl}/v3/teams/${teamName}/users/me` + : `${baseUrl}/api/v3/users/me`; + + const validationResponse = await fetch(validationUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (validationResponse.status === 401 || validationResponse.status === 403) { + return res.status(401).json({ error: 'Invalid Stack Overflow token' }); + } + + if (!validationResponse.ok) { + logger.error(`Token validation failed: ${await validationResponse.text()}`); + return res.status(500).json({ error: 'Failed to validate token' }); + } + + return res + .cookie('stackoverflow-access-token', accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + }) + .json({ ok: true, message: 'Stack Overflow token accepted' }); + + } catch (error: any) { + logger.error('Error setting manual access token:', error); + return res.status(500).json({ error: 'Internal Server Error' }); + } + }); + // Logout route router.post('/logout', async (_req: Request, res: Response) => { @@ -303,4 +354,4 @@ export async function createRouter({ }); return router; -} +} \ No newline at end of file diff --git a/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts b/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts index 24123e0..b0101a7 100644 --- a/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts +++ b/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts @@ -20,7 +20,7 @@ export async function createStackOverflowService({ logger.info('Initializing Stack Overflow Service'); const { baseUrl, teamName } = config; - const api = createStackOverflowApi(baseUrl); + const api = createStackOverflowApi(baseUrl || 'https://api.stackoverflowteams.com'); return { // GET diff --git a/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/types.ts b/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/types.ts index dabdb3f..47b08a5 100644 --- a/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/types.ts +++ b/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/types.ts @@ -53,10 +53,10 @@ export type PaginatedResponse = { }; export type StackOverflowConfig = { - baseUrl: string; + baseUrl?: string; teamName?: string; - clientId: number; - redirectUri: string; + clientId?: number; + redirectUri?: string; authUrl?: string; }; diff --git a/plugins/stack-overflow-teams/package.json b/plugins/stack-overflow-teams/package.json index f6088d6..7ba2968 100644 --- a/plugins/stack-overflow-teams/package.json +++ b/plugins/stack-overflow-teams/package.json @@ -44,6 +44,7 @@ "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", + "@mui/icons-material": "^7.1.0", "@mui/material": "5.16.14", "react-use": "^17.2.4" }, diff --git a/plugins/stack-overflow-teams/src/api/StackOverflowAPI.ts b/plugins/stack-overflow-teams/src/api/StackOverflowAPI.ts index 3b557e2..e1314e1 100644 --- a/plugins/stack-overflow-teams/src/api/StackOverflowAPI.ts +++ b/plugins/stack-overflow-teams/src/api/StackOverflowAPI.ts @@ -27,6 +27,7 @@ export interface StackOverflowAPI { completeAuth(code: string, state: string): Promise; getAuthStatus: () => Promise; logout: () => Promise; + submitAccessToken: (token: string) => Promise; } export const createStackOverflowApi = ( @@ -98,5 +99,13 @@ export const createStackOverflowApi = ( return false; } }, + submitAccessToken: async (token: string): Promise => { + try { + await requestAPI('auth/token', 'POST', { accessToken: token }); + return true; + } catch { + return false; + } + }, }; -}; +}; \ No newline at end of file diff --git a/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx b/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx index e328d9f..4f1f164 100644 --- a/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx +++ b/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx @@ -1,15 +1,50 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { stackoverflowteamsApiRef } from '../../api'; -// eslint-disable-next-line no-restricted-imports -import { Button, Typography, Box } from '@mui/material'; -import { useStackOverflowStyles } from '../StackOverflow/hooks'; // Import the styles hook +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import Paper from '@mui/material/Paper'; +import InputAdornment from '@mui/material/InputAdornment'; +import IconButton from '@mui/material/IconButton'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import Link from '@mui/material/Link'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import { useStackOverflowStyles } from '../StackOverflow/hooks'; import { StackOverflowIcon } from '../../icons'; export const StackOverflowAuthStart = () => { const stackOverflowTeamsApi = useApi(stackoverflowteamsApiRef); const classes = useStackOverflowStyles(); const [authError, setAuthError] = useState(null); + const [accessToken, setAccessToken] = useState(''); + const [showToken, setShowToken] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tokenSuccess, setTokenSuccess] = useState(false); + const [baseUrl, setBaseUrl] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchBaseUrl = async () => { + try { + setIsLoading(true); + const baseUrlValue = await stackOverflowTeamsApi.getBaseUrl(); + setBaseUrl(baseUrlValue); + } catch (error) { + setAuthError('Failed to fetch Stack Overflow instance information.'); + } finally { + setIsLoading(false); + } + }; + + fetchBaseUrl(); + }, [stackOverflowTeamsApi]); + + // Determine if user is on basic or business plan based on the baseUrl + const isBasicOrBusinessPlan = baseUrl === 'https://api.stackoverflowteams.com'; const handleAuth = async () => { try { @@ -17,10 +52,56 @@ export const StackOverflowAuthStart = () => { const authUrl = await stackOverflowTeamsApi.startAuth(); window.location.href = authUrl; } catch (error) { - setAuthError('Something went wrong during authentication. Please try again.'); + setAuthError( + 'Something went wrong during authentication. Please try again.', + ); } }; + const handleTokenSubmit = async () => { + if (!accessToken.trim()) { + setAuthError('Access token cannot be empty'); + return; + } + + setIsSubmitting(true); + setAuthError(null); + + try { + const success = await stackOverflowTeamsApi.submitAccessToken( + accessToken, + ); + + if (success) { + setTokenSuccess(true); + setAccessToken(''); + window.location.reload(); + } else { + setAuthError( + 'Failed to validate token. Please check your token and try again.', + ); + } + } catch (error) { + setAuthError('Network error. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading) { + return ( + + Loading Stack Overflow configuration... + + ); + } + return ( { height="100vh" bgcolor="background.default" > - - Stack Overflow for Teams Login - - - Click the button below to log in with your Stack Overflow for Teams - account. - - - - - - - {authError && ( - - {authError} + + + Stack Overflow for Teams - )} + + {/* Standard OAuth login - only displayed if NOT on basic/business plan */} + {!isBasicOrBusinessPlan && ( + + + Connect with your Stack Overflow for Enterprise account + + + + + + + )} + + {/* Manual PAT token input for basic/business plans */} + {isBasicOrBusinessPlan && ( + + + Enter your Personal Access Token (PAT) + + + + + Learn how to generate a Personal Access Token + + + + setAccessToken(e.target.value)} + type={showToken ? 'text' : 'password'} + disabled={isSubmitting} + InputProps={{ + endAdornment: ( + + setShowToken(!showToken)} + edge="end" + > + {showToken ? : } + + + ), + }} + /> + + + + + + )} + {authError && ( + + {authError} + + )} + + + setTokenSuccess(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + + Stack Overflow token accepted successfully! + + ); }; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 84ef6e8..a73351d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2712,6 +2712,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/runtime@npm:7.27.1" + checksum: 10c0/530a7332f86ac5a7442250456823a930906911d895c0b743bf1852efc88a20a016ed4cd26d442d0ca40ae6d5448111e02a08dd638a4f1064b47d080e2875dc05 + languageName: node + linkType: hard + "@babel/template@npm:^7.25.9, @babel/template@npm:^7.3.3": version: 7.25.9 resolution: "@babel/template@npm:7.25.9" @@ -9415,6 +9422,22 @@ __metadata: languageName: node linkType: hard +"@mui/icons-material@npm:^7.1.0": + version: 7.1.0 + resolution: "@mui/icons-material@npm:7.1.0" + dependencies: + "@babel/runtime": "npm:^7.27.1" + peerDependencies: + "@mui/material": ^7.1.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/d83c5a1506526525fa93053b4ecd4ff498236a415f7594e75252fbec7e4584bb8470db3e04198a4924e8bd67af96cbaf93a9dee0f0ef9fa19a999bd6ade01734 + languageName: node + linkType: hard + "@mui/material@npm:5.16.14": version: 5.16.14 resolution: "@mui/material@npm:5.16.14" @@ -16126,6 +16149,7 @@ __metadata: "@types/jsonwebtoken": "npm:^9" "@types/qs": "npm:^6" "@types/supertest": "npm:^2.0.12" + csrf: "npm:^3.1.0" express: "npm:^4.17.1" express-promise-router: "npm:^4.1.0" jsonwebtoken: "npm:^9.0.2" @@ -16153,6 +16177,7 @@ __metadata: "@material-ui/core": "npm:^4.9.13" "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:^4.0.0-alpha.61" + "@mui/icons-material": "npm:^7.1.0" "@mui/material": "npm:5.16.14" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^14.0.0" @@ -17997,6 +18022,17 @@ __metadata: languageName: node linkType: hard +"csrf@npm:^3.1.0": + version: 3.1.0 + resolution: "csrf@npm:3.1.0" + dependencies: + rndm: "npm:1.2.0" + tsscmp: "npm:1.0.6" + uid-safe: "npm:2.1.5" + checksum: 10c0/9cbc308352d481130d98b7826e447861560e6d09b4629591f9c31e6832f8f338540e2f5be00fc1945f752f549a1bc17954e932e4a8dfc325be41e695dfc63b6a + languageName: node + linkType: hard + "css-box-model@npm:^1.2.0": version: 1.2.1 resolution: "css-box-model@npm:1.2.1" @@ -30520,6 +30556,13 @@ __metadata: languageName: node linkType: hard +"rndm@npm:1.2.0": + version: 1.2.0 + resolution: "rndm@npm:1.2.0" + checksum: 10c0/31788f9a07659b8b13e03c3d68bfa0cf7aab811edca10ba823841db4e830ca7d7eae8decc8cfcb334bc0e974c5022dcd6a1fb9ffd72dac8fa3efa45a4f8bdde5 + languageName: node + linkType: hard + "roarr@npm:^2.15.3": version: 2.15.4 resolution: "roarr@npm:2.15.4" @@ -33311,7 +33354,7 @@ __metadata: languageName: node linkType: hard -"uid-safe@npm:~2.1.5": +"uid-safe@npm:2.1.5, uid-safe@npm:~2.1.5": version: 2.1.5 resolution: "uid-safe@npm:2.1.5" dependencies: From 022d9515e7433c859429a57b7dc8b2c583944c67 Mon Sep 17 00:00:00 2001 From: EstoesMoises Date: Wed, 14 May 2025 09:39:13 +0100 Subject: [PATCH 2/7] making the configuration consistent accross plugins --- app-config.yaml | 5 +- .../config.d.ts | 6 +-- .../StackOverflowQuestionsCollatorFactory.ts | 20 +++++--- .../src/plugin.ts | 25 ++++++++-- .../createStackOverflowService.ts | 47 +++++++++++++++---- 5 files changed, 79 insertions(+), 24 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 65c0855..7d9efa7 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -5,8 +5,11 @@ app: organization: name: My Company +# For Basic and Business tiers, only provide teamName and apiAccessToken +# For Enterprise tier DO NOT provide teamName. + stackoverflow: - # baseUrl: ${STACK_OVERFLOW_INSTANCE_URL} + baseUrl: ${STACK_OVERFLOW_INSTANCE_URL} # Required only for Enteprise Tier. teamName: ${STACK_OVERFLOW_TEAM_NAME} diff --git a/plugins/search-backend-module-stack-overflow-teams-collator/config.d.ts b/plugins/search-backend-module-stack-overflow-teams-collator/config.d.ts index 0dec331..67f5295 100644 --- a/plugins/search-backend-module-stack-overflow-teams-collator/config.d.ts +++ b/plugins/search-backend-module-stack-overflow-teams-collator/config.d.ts @@ -20,9 +20,9 @@ export interface Config { */ stackoverflow: { /** - * The base url of the Stack Overflow API used for the plugin + * The base url of the Stack Overflow API used for the plugin, if no BaseUrl is provided it will default to https://api.stackoverflowteams.com */ - baseUrl: string; + baseUrl?: string; /** * The API Access Token to authenticate to Stack Overflow API Version 3 @@ -31,7 +31,7 @@ export interface Config { apiAccessToken: string; /** - * The name of the team for a Stack Overflow for Teams account + * The name of the team for a Stack Overflow for Teams account. When teamName is provided baseUrl will always be https://api.stackoverflowteams.com */ teamName?: string; diff --git a/plugins/search-backend-module-stack-overflow-teams-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts b/plugins/search-backend-module-stack-overflow-teams-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts index f7995aa..6ac60de 100644 --- a/plugins/search-backend-module-stack-overflow-teams-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts +++ b/plugins/search-backend-module-stack-overflow-teams-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts @@ -133,9 +133,9 @@ export class StackOverflowQuestionsCollatorFactory async *execute(): AsyncGenerator { this.logger.info(`Retrieving data using Stack Overflow API Version 3`); - if (!this.baseUrl) { - this.logger.warn( - `No stackoverflow.baseUrl configured in your app-config.yaml defaulting to https://api.stackoverflowteams.com`, + if (!this.baseUrl && this.teamName) { + this.logger.info( + `Connecting to the Teams API at https://api.stackoverflowteams.com`, ); } @@ -153,13 +153,21 @@ export class StackOverflowQuestionsCollatorFactory let requestUrl; if (this.teamName) { - const basePath = - this.baseUrl === this.stackOverflowTeamsAPI ? '/v3' : '/api/v3'; - requestUrl = `${this.baseUrl}${basePath}/teams/${this.teamName}/questions${params}`; + requestUrl = `${this.stackOverflowTeamsAPI}/v3/teams/${this.teamName}/questions${params}`; } else { requestUrl = `${this.baseUrl}/api/v3/questions${params}`; } + // The code below has been commented, it has potential compatiblity with Enterprise Private Teams but I haven't tested it and since Private Teams is not widely used I've decided to change the logic to prioritise the support for the Basic and Business Teams. + + // if (this.teamName) { + // const basePath = + // this.baseUrl === this.stackOverflowTeamsAPI ? '/v3' : '/api/v3'; + // requestUrl = `${this.baseUrl}${basePath}/teams/${this.teamName}/questions${params}`; + // } else { + // requestUrl = `${this.baseUrl}/api/v3/questions${params}`; + // } + let page = 1; let totalPages = 1; const pageSize = this.requestParams.pageSize || 50; diff --git a/plugins/stack-overflow-teams-backend/src/plugin.ts b/plugins/stack-overflow-teams-backend/src/plugin.ts index 5747464..e2c8d35 100644 --- a/plugins/stack-overflow-teams-backend/src/plugin.ts +++ b/plugins/stack-overflow-teams-backend/src/plugin.ts @@ -22,13 +22,28 @@ export const stackOverflowTeamsPlugin = createBackendPlugin({ config: coreServices.rootConfig, }, async init({ logger, httpRouter, config }) { - const forceOriginUrl = (baseUrl: string) : string => `${new URL(baseUrl).origin}` + const forceOriginUrl = (baseUrl: string): string => + `${new URL(baseUrl).origin}`; + + const teamName = config.getOptionalString('stackoverflow.teamName'); + + // If teamName is provided, always use api.stackoverflowteams.com + const baseUrl = teamName + ? 'https://api.stackoverflowteams.com' + : forceOriginUrl( + config.getOptionalString('stackoverflow.baseUrl') || + 'https://api.stackoverflowteams.com', + ); + const stackOverflowConfig: StackOverflowConfig = { - baseUrl: forceOriginUrl(config.getOptionalString('stackoverflow.baseUrl') || 'https://api.stackoverflowteams.com'), - teamName: config.getOptionalString('stackoverflow.teamName'), + baseUrl, + teamName, clientId: config.getOptionalNumber('stackoverflow.clientId'), - redirectUri: config.getOptionalString('stackoverflow.redirectUri') || `${config.getString('app.baseUrl')}/stack-overflow-teams` + redirectUri: + config.getOptionalString('stackoverflow.redirectUri') || + `${config.getString('app.baseUrl')}/stack-overflow-teams`, }; + const stackOverflowService = await createStackOverflowService({ config: stackOverflowConfig, logger, @@ -38,7 +53,7 @@ export const stackOverflowTeamsPlugin = createBackendPlugin({ await createRouter({ stackOverflowConfig, logger, - stackOverflowService + stackOverflowService, }), ); }, diff --git a/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts b/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts index b0101a7..0b29098 100644 --- a/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts +++ b/plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts @@ -17,21 +17,50 @@ export async function createStackOverflowService({ config: StackOverflowConfig; logger: LoggerService; }): Promise { + // LOGGER + logger.info('Initializing Stack Overflow Service'); + if (config.baseUrl && config.teamName) { + logger.warn( + "Please note that this integration is not compatible with Enterprise Private Teams. When stackoverflow.teamName is provided the baseUrl will always change to 'api.stackoverflowteams.com'", + ); + } + const { baseUrl, teamName } = config; - const api = createStackOverflowApi(baseUrl || 'https://api.stackoverflowteams.com'); + const api = createStackOverflowApi( + baseUrl || 'https://api.stackoverflowteams.com', + ); return { // GET - getQuestions: (authToken) => api.GET>('/questions', authToken, teamName), - getTags: (authToken) => api.GET>('/tags', authToken, teamName), - getUsers: (authToken) => api.GET>('/users', authToken, teamName), - getMe: (authToken) => api.GET('/users/me', authToken, teamName), + getQuestions: authToken => + api.GET>('/questions', authToken, teamName), + getTags: authToken => + api.GET>('/tags', authToken, teamName), + getUsers: authToken => + api.GET>('/users', authToken, teamName), + getMe: authToken => api.GET('/users/me', authToken, teamName), // POST - postQuestions: (title: string, body: string, tags: string[], authToken: string) => - api.POST('/questions', { title, body, tags }, authToken, teamName), + postQuestions: ( + title: string, + body: string, + tags: string[], + authToken: string, + ) => + api.POST( + '/questions', + { title, body, tags }, + authToken, + teamName, + ), // SEARCH - getSearch: (query: string, authToken: string) => api.SEARCH>('/search', query, authToken, teamName) + getSearch: (query: string, authToken: string) => + api.SEARCH>( + '/search', + query, + authToken, + teamName, + ), }; -} \ No newline at end of file +} From 18f34007e1ed7639a0a317a5e3c84e5a02363782 Mon Sep 17 00:00:00 2001 From: EstoesMoises Date: Wed, 14 May 2025 09:55:12 +0100 Subject: [PATCH 3/7] Fixed issue with BaseUrl will now change depending on whether we are calling a B&B Team or not --- .../src/router.ts | 56 +++++++++++-------- .../src/api/StackOverflowAPI.ts | 6 ++ .../StackOverflowAuth/StackAuthStart.tsx | 14 ++--- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/plugins/stack-overflow-teams-backend/src/router.ts b/plugins/stack-overflow-teams-backend/src/router.ts index ab360b9..c6ab8e3 100644 --- a/plugins/stack-overflow-teams-backend/src/router.ts +++ b/plugins/stack-overflow-teams-backend/src/router.ts @@ -38,7 +38,7 @@ export async function createRouter({ const cookiesToken = cookies['stackoverflow-access-token']; try { - const authToken = cookiesToken + const authToken = cookiesToken; if (!authToken) { res.clearCookie('stackoverflow-access-token'); return null; @@ -124,10 +124,10 @@ export async function createRouter({ const baseUrl = stackOverflowConfig.baseUrl; const teamName = stackOverflowConfig.teamName; - + // Use the team-specific API endpoint for basic and business teams - const userApiUrl = teamName - ? `${baseUrl}/v3/teams/${teamName}/users/me` + const userApiUrl = teamName + ? `${baseUrl}/v3/teams/${teamName}/users/me` : `${baseUrl}/api/v3/users/me`; const userResponse = await fetch(userApiUrl, { @@ -159,33 +159,39 @@ export async function createRouter({ router.post('/auth/token', async (req: Request, res: Response) => { try { const { accessToken } = req.body; - + if (!accessToken || typeof accessToken !== 'string') { - return res.status(400).json({ error: 'Valid access token is required' }); + return res + .status(400) + .json({ error: 'Valid access token is required' }); } - - // Don't use authService here, use the config directly + const baseUrl = stackOverflowConfig.baseUrl; const teamName = stackOverflowConfig.teamName; - + // Use the team-specific API endpoint for basic and business teams - const validationUrl = teamName - ? `${baseUrl}/v3/teams/${teamName}/users/me` + const validationUrl = teamName + ? `${baseUrl}/v3/teams/${teamName}/users/me` : `${baseUrl}/api/v3/users/me`; - + const validationResponse = await fetch(validationUrl, { headers: { Authorization: `Bearer ${accessToken}` }, }); - - if (validationResponse.status === 401 || validationResponse.status === 403) { + + if ( + validationResponse.status === 401 || + validationResponse.status === 403 + ) { return res.status(401).json({ error: 'Invalid Stack Overflow token' }); } - + if (!validationResponse.ok) { - logger.error(`Token validation failed: ${await validationResponse.text()}`); + logger.error( + `Token validation failed: ${await validationResponse.text()}`, + ); return res.status(500).json({ error: 'Failed to validate token' }); } - + return res .cookie('stackoverflow-access-token', accessToken, { httpOnly: true, @@ -193,7 +199,6 @@ export async function createRouter({ sameSite: 'strict', }) .json({ ok: true, message: 'Stack Overflow token accepted' }); - } catch (error: any) { logger.error('Error setting manual access token:', error); return res.status(500).json({ error: 'Internal Server Error' }); @@ -217,12 +222,19 @@ export async function createRouter({ // Info routes router.get('/baseurl', async (_req: Request, res: Response) => { - const baseUrl = stackOverflowConfig.baseUrl; try { - res.json({ SOInstance: baseUrl }); + const baseUrl = stackOverflowConfig.baseUrl; + const teamsAPIUrl = 'https://api.stackoverflowteams.com'; + const teamsBaseUrl = `https://stackoverflowteams.com/c/${stackOverflowConfig.teamName}`; // Fixed URL to match your previous code + + if (baseUrl === teamsAPIUrl) { + return res.json({ SOInstance: teamsBaseUrl, teamName: stackOverflowConfig.teamName }); + } + + return res.json({ SOInstance: baseUrl }); } catch (error) { console.error('Error fetching Stack Overflow base URL:', error); - res + return res .status(500) .json({ error: 'Failed to fetch Stack Overflow base URL' }); } @@ -354,4 +366,4 @@ export async function createRouter({ }); return router; -} \ No newline at end of file +} diff --git a/plugins/stack-overflow-teams/src/api/StackOverflowAPI.ts b/plugins/stack-overflow-teams/src/api/StackOverflowAPI.ts index e1314e1..e2be5d9 100644 --- a/plugins/stack-overflow-teams/src/api/StackOverflowAPI.ts +++ b/plugins/stack-overflow-teams/src/api/StackOverflowAPI.ts @@ -13,6 +13,7 @@ type ApiResponse = PaginatedResponse; interface BaseUrlResponse { SOInstance: string; + teamName: string } export interface StackOverflowAPI { @@ -22,6 +23,7 @@ export interface StackOverflowAPI { getUsers(): Promise>; getMe(): Promise; getBaseUrl(): Promise; + getTeamName(): Promise; postQuestion(title: string, body: string, tags: string[]): Promise; startAuth(): Promise; completeAuth(code: string, state: string): Promise; @@ -71,6 +73,10 @@ export const createStackOverflowApi = ( const response = await requestAPI('baseurl'); return response.SOInstance; }, + getTeamName: async () => { + const response = await requestAPI('baseurl') + return response.teamName + }, postQuestion: (title: string, body: string, tags: string[]) => requestAPI('questions', 'POST', { title, body, tags }), startAuth: async () => { diff --git a/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx b/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx index 4f1f164..977f514 100644 --- a/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx +++ b/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx @@ -24,15 +24,15 @@ export const StackOverflowAuthStart = () => { const [showToken, setShowToken] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [tokenSuccess, setTokenSuccess] = useState(false); - const [baseUrl, setBaseUrl] = useState(null); + const [teamName, setTeamName] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { - const fetchBaseUrl = async () => { + const fetchTeamName = async () => { try { setIsLoading(true); - const baseUrlValue = await stackOverflowTeamsApi.getBaseUrl(); - setBaseUrl(baseUrlValue); + const teamNameValue = await stackOverflowTeamsApi.getTeamName(); + setTeamName(teamNameValue); } catch (error) { setAuthError('Failed to fetch Stack Overflow instance information.'); } finally { @@ -40,11 +40,11 @@ export const StackOverflowAuthStart = () => { } }; - fetchBaseUrl(); + fetchTeamName(); }, [stackOverflowTeamsApi]); - // Determine if user is on basic or business plan based on the baseUrl - const isBasicOrBusinessPlan = baseUrl === 'https://api.stackoverflowteams.com'; + // Determine if user is on basic or business plan based on the teamName + const isBasicOrBusinessPlan = Boolean(teamName) const handleAuth = async () => { try { From d3f864f6bd4aabdb5c880b479ce28e252906da61 Mon Sep 17 00:00:00 2001 From: EstoesMoises Date: Wed, 14 May 2025 09:57:03 +0100 Subject: [PATCH 4/7] changing button colour to orange --- .../src/components/StackOverflowAuth/StackAuthStart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx b/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx index 977f514..f61d835 100644 --- a/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx +++ b/plugins/stack-overflow-teams/src/components/StackOverflowAuth/StackAuthStart.tsx @@ -212,6 +212,7 @@ export const StackOverflowAuthStart = () => { fullWidth onClick={handleTokenSubmit} disabled={isSubmitting || !accessToken.trim()} + className={classes.button} > {isSubmitting ? 'Validating...' : 'Submit Token'} From 67fa259fd1c4b28e38338fc364080cb2b71fb0d5 Mon Sep 17 00:00:00 2001 From: EstoesMoises Date: Wed, 14 May 2025 10:04:12 +0100 Subject: [PATCH 5/7] fixing tsc now that redirectUri and clientId are optional --- app-config.yaml | 4 +-- .../src/api/createStackOverflowAuth.ts | 28 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 7d9efa7..6e437a3 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -18,10 +18,10 @@ stackoverflow: apiAccessToken: ${STACK_OVERFLOW_API_ACCESS_TOKEN} # The API Access Token is used for the Questions' collator, a no-expiry, read-only token is recommended. - clientId: ${STACK_OVERFLOW_CLIENT_ID} + # clientId: ${STACK_OVERFLOW_CLIENT_ID} # The clientid must be for an API Application with read-write access. Only provide if you are using the Enterprise tier. - redirectUri: ${STACK_OVERFLOW_REDIRECT_URI} + # redirectUri: ${STACK_OVERFLOW_REDIRECT_URI} # If no redirectUri is specified this will return to https:///stack-overflow-teams. Only provide if you are using the Enterprise tier. backend: diff --git a/plugins/stack-overflow-teams-backend/src/api/createStackOverflowAuth.ts b/plugins/stack-overflow-teams-backend/src/api/createStackOverflowAuth.ts index 4eaceb0..0d2c90d 100644 --- a/plugins/stack-overflow-teams-backend/src/api/createStackOverflowAuth.ts +++ b/plugins/stack-overflow-teams-backend/src/api/createStackOverflowAuth.ts @@ -6,7 +6,6 @@ export function createStackOverflowAuth( config: StackOverflowConfig, logger: LoggerService, ) { - async function generatePKCECodeVerifier(): Promise<{ codeVerifier: string; codeChallenge: string; @@ -19,7 +18,16 @@ export function createStackOverflowAuth( return { codeVerifier, codeChallenge: hashed }; } - async function getAuthUrl(): Promise<{ url: string; codeVerifier: string ; state: string}> { + async function getAuthUrl(): Promise<{ + url: string; + codeVerifier: string; + state: string; + }> { + if (!config.clientId || !config.redirectUri) { + throw new Error( + 'clientId and redirectUri are required for authentication', + ); + } const { codeVerifier, codeChallenge } = await generatePKCECodeVerifier(); const state = crypto.randomBytes(16).toString('hex'); const authUrl = `${config.baseUrl}/oauth?client_id=${ @@ -34,7 +42,12 @@ export function createStackOverflowAuth( async function exchangeCodeForToken( code: string, codeVerifier: string, - ): Promise<{accessToken: string, expires: number}> { + ): Promise<{ accessToken: string; expires: number }> { + if (!config.clientId || !config.redirectUri) { + throw new Error( + 'clientId and redirectUri are required for authentication', + ); + } const tokenUrl = `${config.baseUrl}/oauth/access_token/json`; const queryParams = new URLSearchParams({ client_id: String(config.clientId), @@ -42,12 +55,11 @@ export function createStackOverflowAuth( redirect_uri: config.redirectUri, code_verifier: codeVerifier, }); - + const response = await fetch(`${tokenUrl}?${queryParams.toString()}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); - if (!response.ok) { logger.error('Failed to exchange code for access token'); @@ -56,13 +68,13 @@ export function createStackOverflowAuth( const data = await response.json(); return { accessToken: data.access_token, - expires: data.expires - } + expires: data.expires, + }; } return { getAuthUrl, exchangeCodeForToken, - config: config + config: config, }; } From b60ecd73110bd8b914f1b190b61e6bbcbd3a88fa Mon Sep 17 00:00:00 2001 From: EstoesMoises Date: Wed, 14 May 2025 10:07:12 +0100 Subject: [PATCH 6/7] fixing configuration --- app-config.docker-local.yaml | 9 ++++++--- app-config.yaml | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app-config.docker-local.yaml b/app-config.docker-local.yaml index 9508a5e..b6c3687 100644 --- a/app-config.docker-local.yaml +++ b/app-config.docker-local.yaml @@ -12,16 +12,19 @@ organization: stackoverflow: baseUrl: ${STACK_OVERFLOW_INSTANCE_URL} - # teamName: ${STACK_OVERFLOW_TEAM_NAME} + # Required only for Enteprise Tier. + + teamName: ${STACK_OVERFLOW_TEAM_NAME} + # Required only for Basic and Business Tiers. apiAccessToken: ${STACK_OVERFLOW_API_ACCESS_TOKEN} # The API Access Token is used for the Questions' collator, a no-expiry, read-only token is recommended. clientId: ${STACK_OVERFLOW_CLIENT_ID} - # The clientid must be for an API Application with read-write access. + # The clientid must be for an API Application with read-write access. Only provide if you are using the Enterprise tier. redirectUri: ${STACK_OVERFLOW_REDIRECT_URI} - # If no redirectUri is specified this will return to https:///stack-overflow-teams + # If no redirectUri is specified this will return to https:///stack-overflow-teams. Only provide if you are using the Enterprise tier. backend: # Used for enabling authentication, secret is shared by all backend plugins diff --git a/app-config.yaml b/app-config.yaml index 6e437a3..7d9efa7 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -18,10 +18,10 @@ stackoverflow: apiAccessToken: ${STACK_OVERFLOW_API_ACCESS_TOKEN} # The API Access Token is used for the Questions' collator, a no-expiry, read-only token is recommended. - # clientId: ${STACK_OVERFLOW_CLIENT_ID} + clientId: ${STACK_OVERFLOW_CLIENT_ID} # The clientid must be for an API Application with read-write access. Only provide if you are using the Enterprise tier. - # redirectUri: ${STACK_OVERFLOW_REDIRECT_URI} + redirectUri: ${STACK_OVERFLOW_REDIRECT_URI} # If no redirectUri is specified this will return to https:///stack-overflow-teams. Only provide if you are using the Enterprise tier. backend: From 15410e76ea3324bfec2e33b0ba7abd4dad4872f9 Mon Sep 17 00:00:00 2001 From: EstoesMoises Date: Wed, 21 May 2025 11:28:10 +0100 Subject: [PATCH 7/7] Updated README --- README.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 227f778..b339c34 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,33 @@ This image runs a Backstage instance pre-configured with the Stack Overflow for --- -### Required Environment Variables +## ๐Ÿ“ฆ Required Environment Variables -| Variable | Description | -|:----------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `STACK_OVERFLOW_INSTANCE_URL` | The base URL of your Stack Overflow for Teams (Enterprise) instance. | -| `STACK_OVERFLOW_API_ACCESS_TOKEN` | A **read-only, no-expiry** API access token generated for your Stack Overflow Enterprise instance. This token is used by the plugin's search collator to index questions into Backstage search. | -| `STACK_OVERFLOW_CLIENT_ID` | The OAuth Client ID from your Stack Overflow application. This is required to enable the secure question creation flow from within Backstage. | +### For **Enterprise** Customers: + +| Variable | Description | +| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STACK_OVERFLOW_INSTANCE_URL` | The base URL of your Stack Overflow for Teams (Enterprise) instance. | +| `STACK_OVERFLOW_API_ACCESS_TOKEN` | A **read-only, no-expiry** API access token generated for your Stack Overflow Enterprise instance. This token is used by the pluginโ€™s search collator to index questions into Backstage search. | +| `STACK_OVERFLOW_CLIENT_ID` | The OAuth Client ID from your Stack Overflow application. This is required to enable the secure question creation flow from within Backstage. | | `STACK_OVERFLOW_REDIRECT_URI` | The redirect URI where Stack Overflow should send users after completing the OAuth authentication flow. By default, this is `{app.baseUrl}/stack-overflow-teams`. For local development, you can use a redirect service like `http://redirectmeto.com/http://localhost:7007/stack-overflow-teams`. | +--- + +### For **Basic** and **Business** Customers: + +| Variable | Description | +| :-------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | +| `STACK_OVERFLOW_TEAM_NAME` | The **team name** or **team slug** from your Stack Overflow for Teams account. | +| `STACK_OVERFLOW_API_ACCESS_TOKEN` | A **read-only, no-expiry** API access token generated for your Stack Overflow Teams instance. Used for indexing content. | + +๐Ÿ“– How to generate your API Access Token + +Basic and Business customers can follow the official Stack Overflow for Teams guide to create a Personal Access Token (PAT) for API authentication: + +๐Ÿ‘‰ [Personal Access Tokens (PATs) for API Authentication](https://stackoverflowteams.help/en/articles/10908790-personal-access-tokens-pats-for-api-authentication) + +This token should have read-only access and no expiration to be used for indexing questions into Backstage search. ---