diff --git a/.changeset/gentle-ado-setup.md b/.changeset/gentle-ado-setup.md new file mode 100644 index 00000000..3cc8927d --- /dev/null +++ b/.changeset/gentle-ado-setup.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': patch +--- + +Guide Azure DevOps setup through organization-first PAT creation, require Microsoft Entra account linking for Azure DevOps Services, automatically reuse existing Microsoft or Teams app credentials, clarify when a tenant ID is required, hide advanced credentials by default, and allow saved optional source-control settings to be cleared. diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 104b6ffd..de8739c8 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -229,43 +229,43 @@ as per-task auth tokens or workspace paths. ### Source control providers -| Env var | Required | Used for | -| ------------------------------ | ------------ | ------------------------------------------------------------------------------------------------ | -| `R_GITHUB_APP_SLUG` | GitHub | GitHub App slug. | -| `R_GITHUB_APP_ID` | GitHub | GitHub App ID. | -| `R_GITHUB_APP_PRIVATE_KEY` | GitHub | Raw GitHub App private key PEM, usually with newlines escaped as `\\n`. | -| `R_GITHUB_CLIENT_ID` | GitHub | GitHub OAuth client ID. | -| `R_GITHUB_CLIENT_SECRET` | GitHub | GitHub OAuth client secret. | -| `R_GITHUB_WEBHOOK_SECRET` | GitHub | GitHub webhook secret. | -| `GITHUB_MCP_SERVER_URL` | Optional | GitHub MCP server URL override. | -| `GITHUB_AUTOMATED_SKIP_REPOS` | Optional | Repository skip list for automated GitHub processing. | -| `GITHUB_AUTOMATED_SKIP_OWNERS` | Optional | Owner skip list for automated GitHub processing. | -| `GITLAB_TOKEN` | GitLab | GitLab personal access token for source-control setup. | -| `GITLAB_BASE_URL` | Optional | GitLab base URL for self-managed GitLab. | -| `GITLAB_CLIENT_ID` | Optional | GitLab OAuth application ID for personal account linking and merge request comment triggers. | -| `GITLAB_CLIENT_SECRET` | Optional | GitLab OAuth application secret for personal account linking and merge request comment triggers. | -| `GITLAB_WEBHOOK_SIGNING_TOKEN` | Optional | GitLab webhook signing token. | -| `GITLAB_WEBHOOK_SECRET` | Optional | GitLab webhook secret. | -| `GITEA_BASE_URL` | Gitea | Gitea base URL. | -| `GITEA_TOKEN` | Gitea | Gitea access token. | -| `GITEA_USERNAME` | Optional | Gitea username. | -| `GITEA_CLIENT_ID` | Optional | Gitea OAuth client ID. | -| `GITEA_CLIENT_SECRET` | Optional | Gitea OAuth client secret. | -| `GITEA_WEBHOOK_SECRET` | Optional | Gitea webhook secret. | -| `BITBUCKET_TOKEN` | Bitbucket | Atlassian API token with Bitbucket scopes. | -| `BITBUCKET_USERNAME` | Bitbucket | Atlassian account email that owns the API token. | -| `BITBUCKET_BASE_URL` | Optional | Bitbucket base URL. Defaults to `https://bitbucket.org` (Cloud only). | -| `BITBUCKET_CLIENT_ID` | Optional | Bitbucket OAuth client ID for personal account linking. | -| `BITBUCKET_CLIENT_SECRET` | Optional | Bitbucket OAuth client secret for personal account linking. | -| `BITBUCKET_WEBHOOK_SECRET` | Optional | Bitbucket webhook secret. | -| `ADO_ORGANIZATION` | Azure DevOps | Azure DevOps organization. | -| `ADO_TOKEN` | Azure DevOps | Azure DevOps access token. | -| `ADO_BASE_URL` | Optional | Azure DevOps base URL. | -| `ADO_USERNAME` | Optional | Azure DevOps username. | -| `ADO_CLIENT_ID` | Optional | Microsoft Entra client ID for Azure DevOps OAuth. | -| `ADO_CLIENT_SECRET` | Optional | Microsoft Entra client secret for Azure DevOps OAuth. | -| `ADO_TENANT_ID` | Optional | Microsoft Entra tenant ID for Azure DevOps OAuth. `R_MICROSOFT_TENANT_ID` is also accepted. | -| `ADO_WEBHOOK_SECRET` | Optional | Azure DevOps webhook secret. | +| Env var | Required | Used for | +| ------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `R_GITHUB_APP_SLUG` | GitHub | GitHub App slug. | +| `R_GITHUB_APP_ID` | GitHub | GitHub App ID. | +| `R_GITHUB_APP_PRIVATE_KEY` | GitHub | Raw GitHub App private key PEM, usually with newlines escaped as `\\n`. | +| `R_GITHUB_CLIENT_ID` | GitHub | GitHub OAuth client ID. | +| `R_GITHUB_CLIENT_SECRET` | GitHub | GitHub OAuth client secret. | +| `R_GITHUB_WEBHOOK_SECRET` | GitHub | GitHub webhook secret. | +| `GITHUB_MCP_SERVER_URL` | Optional | GitHub MCP server URL override. | +| `GITHUB_AUTOMATED_SKIP_REPOS` | Optional | Repository skip list for automated GitHub processing. | +| `GITHUB_AUTOMATED_SKIP_OWNERS` | Optional | Owner skip list for automated GitHub processing. | +| `GITLAB_TOKEN` | GitLab | GitLab personal access token for source-control setup. | +| `GITLAB_BASE_URL` | Optional | GitLab base URL for self-managed GitLab. | +| `GITLAB_CLIENT_ID` | Optional | GitLab OAuth application ID for personal account linking and merge request comment triggers. | +| `GITLAB_CLIENT_SECRET` | Optional | GitLab OAuth application secret for personal account linking and merge request comment triggers. | +| `GITLAB_WEBHOOK_SIGNING_TOKEN` | Optional | GitLab webhook signing token. | +| `GITLAB_WEBHOOK_SECRET` | Optional | GitLab webhook secret. | +| `GITEA_BASE_URL` | Gitea | Gitea base URL. | +| `GITEA_TOKEN` | Gitea | Gitea access token. | +| `GITEA_USERNAME` | Optional | Gitea username. | +| `GITEA_CLIENT_ID` | Optional | Gitea OAuth client ID. | +| `GITEA_CLIENT_SECRET` | Optional | Gitea OAuth client secret. | +| `GITEA_WEBHOOK_SECRET` | Optional | Gitea webhook secret. | +| `BITBUCKET_TOKEN` | Bitbucket | Atlassian API token with Bitbucket scopes. | +| `BITBUCKET_USERNAME` | Bitbucket | Atlassian account email that owns the API token. | +| `BITBUCKET_BASE_URL` | Optional | Bitbucket base URL. Defaults to `https://bitbucket.org` (Cloud only). | +| `BITBUCKET_CLIENT_ID` | Optional | Bitbucket OAuth client ID for personal account linking. | +| `BITBUCKET_CLIENT_SECRET` | Optional | Bitbucket OAuth client secret for personal account linking. | +| `BITBUCKET_WEBHOOK_SECRET` | Optional | Bitbucket webhook secret. | +| `ADO_ORGANIZATION` | Azure DevOps | Azure DevOps organization. | +| `ADO_TOKEN` | Azure DevOps | Azure DevOps access token. | +| `ADO_BASE_URL` | Optional | Azure DevOps base URL. | +| `ADO_USERNAME` | Optional | Azure DevOps username. | +| `ADO_CLIENT_ID` | Optional | Microsoft Entra client ID for Azure DevOps OAuth. Falls back to `R_MICROSOFT_CLIENT_ID`. | +| `ADO_CLIENT_SECRET` | Optional | Microsoft Entra client secret for Azure DevOps OAuth. Falls back to `R_MICROSOFT_CLIENT_SECRET`. | +| `ADO_TENANT_ID` | Conditional | Microsoft Entra tenant ID for Azure DevOps OAuth. Required unless the app is multi-tenant; falls back to `R_MICROSOFT_TENANT_ID`, then `common`. | +| `ADO_WEBHOOK_SECRET` | Optional | Azure DevOps webhook secret. | ### Communications and sign-in @@ -294,7 +294,7 @@ as per-task auth tokens or workspace paths. | `R_SLACK_CLIENT_SECRET` | Slack sign-in | Slack sign-in client secret. `R_SLACK_CLIENT_SECRET` is also accepted. | | `R_MICROSOFT_CLIENT_ID` | Microsoft sign-in | Microsoft OAuth client ID. | | `R_MICROSOFT_CLIENT_SECRET` | Microsoft sign-in | Microsoft OAuth client secret. | -| `R_MICROSOFT_TENANT_ID` | Microsoft sign-in | Microsoft tenant ID. Also accepted as the Azure DevOps tenant ID fallback. | +| `R_MICROSOFT_TENANT_ID` | Microsoft sign-in | Microsoft tenant ID. | | `R_ALLOWED_EMAILS` | Optional | Comma-separated email allowlist for deployments that restrict sign-in by email. | ### Integrations diff --git a/apps/docs/providers/source-control/azure-devops.mdx b/apps/docs/providers/source-control/azure-devops.mdx index 159bfa32..64d706d0 100644 --- a/apps/docs/providers/source-control/azure-devops.mdx +++ b/apps/docs/providers/source-control/azure-devops.mdx @@ -1,13 +1,15 @@ --- title: Azure DevOps icon: 'https://api.iconify.design/simple-icons:azuredevops.svg?color=currentColor' -description: Configure Azure DevOps repository sync for Roomote. +description: Connect Azure DevOps repositories and user accounts to Roomote. --- -Azure DevOps support uses a deployment-owned personal access token and -organization. There is no self-serve OAuth installation flow, service hook -ingestion, or Review Code automation path yet, so an operator provides Azure -DevOps credentials and syncs repositories from Settings. +Azure DevOps uses two separate credentials: + +- a deployment-owned personal access token (PAT) lets Roomote sync and work in + repositories +- Microsoft Entra OAuth identifies individual Roomote users when they start + work from pull request comments ## Create an Azure DevOps PAT @@ -34,6 +36,45 @@ ADO_USERNAME=ado `ADO_BASE_URL` defaults to `https://dev.azure.com`. `ADO_USERNAME` defaults to `ado` and is used as the Git-over-HTTPS username paired with the PAT. +## Link individual user accounts + +Roomote requires account linking when setting up Azure DevOps Services so the +integration is ready for pull request comment triggers. Azure DevOps Server +uses the PAT flow without Microsoft Entra account linking. + +1. Open [Microsoft Entra app registrations](https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) + and create or select an app registration. +2. Add this **Web** redirect URI, replacing the origin with your Roomote URL: + + ```text + https:///api/auth/oauth2/callback/ado + ``` + +3. Under **API permissions**, add the Azure DevOps delegated + `user_impersonation` permission. +4. For a new app, create a client secret, then enter the application ID and + secret in the Azure DevOps account-linking section during setup or in + Settings. +5. Select **Save and link account** and complete Microsoft sign-in. + +If Microsoft Teams or Microsoft sign-in is already configured, you can reuse +that Microsoft Entra app registration. Add the Azure DevOps redirect URI and +permission to the existing app. Roomote automatically reuses the stored +Microsoft client ID, client secret, and tenant ID when Azure DevOps-specific +values are not configured, so you do not need to re-enter the secret. + +The equivalent environment variables are: + +```sh +ADO_CLIENT_ID= +ADO_CLIENT_SECRET= +ADO_TENANT_ID= +``` + +`ADO_TENANT_ID` is required unless the Entra app supports multiple tenants. It +reuses `R_MICROSOFT_TENANT_ID` when available, then defaults to Microsoft's +`common` tenant for multi-tenant apps. + ## Sync repositories After the Azure DevOps values are available, open Settings, go to the @@ -44,22 +85,18 @@ button. Roomote lists repositories from: https://dev.azure.com//_apis/git/repositories?api-version=7.1 ``` -Roomote stores the results as Azure DevOps repository rows. +Roomote stores the results as Azure DevOps repository rows and configures pull +request service hooks when the deployment has a publicly reachable URL. Azure DevOps-backed tasks clone from the synced repository row, so sync must run before launching an Azure DevOps-backed task. Worker tasks write host- and clone-path-scoped Azure DevOps credentials into the file-backed Git credential helper instead of exporting `ADO_TOKEN` into task shells. -## Current limits - -Azure DevOps service hook ingestion and Review Code automation are not -implemented yet. Azure DevOps support currently covers repository sync and -Azure DevOps-backed manual or environment launches. - ## Verify setup 1. sync Azure DevOps repositories from Settings 2. create or update an environment from a synced repository 3. start a small task and confirm Roomote can clone the repository 4. confirm Roomote can push a branch when the task produces changes +5. for Azure DevOps Services, trigger a small task from a pull request comment diff --git a/apps/docs/source-control.mdx b/apps/docs/source-control.mdx index 9763aaf7..117b8530 100644 --- a/apps/docs/source-control.mdx +++ b/apps/docs/source-control.mdx @@ -15,13 +15,13 @@ to review events or comments when the provider supports them. ## Supported providers -| Provider | Best for | Setup model | -| -------- | -------- | ----------- | -| | GitHub-hosted repositories and pull request review workflows | GitHub App installation (default provider). | -| | Azure DevOps repositories | Deployment-owned PAT and repository sync. | -| | Bitbucket Cloud repositories and pull request workflows | Deployment-owned Atlassian API token and repository sync. | -| | Self-hosted Gitea repositories | Deployment-owned token and repository sync. | -| | GitLab projects and merge request review workflows | Deployment-owned token and optional webhooks. | +| Provider | Best for | Setup model | +| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------- | +| | GitHub-hosted repositories and pull request review workflows | GitHub App installation (default provider). | +| | Azure DevOps repositories | Deployment-owned PAT, repository sync, and Microsoft Entra account linking. | +| | Bitbucket Cloud repositories and pull request workflows | Deployment-owned Atlassian API token and repository sync. | +| | Self-hosted Gitea repositories | Deployment-owned token and repository sync. | +| | GitLab projects and merge request review workflows | Deployment-owned token and optional webhooks. | ## Setup checklist diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx index 35d96035..1a5b73da 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.client.test.tsx @@ -1,23 +1,31 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import type { SetupSourceControlStatus } from '@roomote/types'; - -const { createGitHubAppManifestMock, saveMutationOptionsRef } = vi.hoisted( - () => ({ - createGitHubAppManifestMock: vi.fn(), - saveMutationOptionsRef: { - current: null as { - mutationFn?: (variables: unknown) => Promise; - } | null, - }, - }), -); +import { + buildSetupSourceControlStatus, + type SetupSourceControlStatus, +} from '@roomote/types'; + +const { + authenticateAdoAccountMock, + createGitHubAppManifestMock, + mutateAsyncMock, + saveMutationOptionsRef, +} = vi.hoisted(() => ({ + authenticateAdoAccountMock: vi.fn(), + createGitHubAppManifestMock: vi.fn(), + mutateAsyncMock: vi.fn(), + saveMutationOptionsRef: { + current: null as { + mutationFn?: (variables: unknown) => Promise; + } | null, + }, +})); vi.mock('@tanstack/react-query', () => ({ useMutation: (options: typeof saveMutationOptionsRef.current) => { saveMutationOptionsRef.current = options; return { - mutateAsync: vi.fn(), + mutateAsync: mutateAsyncMock, isPending: false, }; }, @@ -46,6 +54,17 @@ vi.mock('@/hooks/github', () => ({ }), })); +vi.mock('@/hooks/linked-accounts', () => ({ + useAdoLinkedAccount: () => ({ + data: { configured: false, account: null }, + isPending: false, + }), + useAuthenticateAdoAccount: () => ({ + mutate: authenticateAdoAccountMock, + isPending: false, + }), +})); + import { StepSourceControlConfig } from './StepSourceControlConfig'; function buildSourceControlSetup( @@ -120,6 +139,7 @@ function buildSourceControlSetup( describe('StepSourceControlConfig', () => { beforeEach(() => { vi.clearAllMocks(); + mutateAsyncMock.mockResolvedValue(undefined); }); it('defaults GitHub setup to the manifest CTA', () => { @@ -216,4 +236,329 @@ describe('StepSourceControlConfig', () => { screen.queryByRole('button', { name: 'Create GitHub App' }), ).not.toBeInTheDocument(); }); + + it('asks for the Azure DevOps organization before showing PAT setup', () => { + render( + , + ); + + const organizationInput = screen.getByLabelText( + 'Azure DevOps Organization', + ); + const continueButton = screen.getByRole('button', { name: 'Continue' }); + + expect(organizationInput).toBeInTheDocument(); + expect(continueButton).toBeDisabled(); + expect( + screen.queryByLabelText('Azure DevOps Access Token'), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'Create Azure DevOps PAT' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText(/Azure DevOps Base URL/), + ).not.toBeInTheDocument(); + + fireEvent.change(organizationInput, { target: { value: ' acme ' } }); + + expect(continueButton).toBeEnabled(); + }); + + it('rejects organization URLs and malformed cloud organization names', () => { + render( + , + ); + + const organizationInput = screen.getByLabelText( + 'Azure DevOps Organization', + ); + const continueButton = screen.getByRole('button', { name: 'Continue' }); + + fireEvent.change(organizationInput, { + target: { value: 'https://dev.azure.com/acme' }, + }); + + expect(organizationInput).toHaveAttribute('aria-invalid', 'true'); + expect(continueButton).toBeDisabled(); + expect( + screen.getByText(/Use only letters, numbers, and hyphens/), + ).toBeVisible(); + + fireEvent.change(organizationInput, { + target: { value: 'acme org' }, + }); + + expect(continueButton).toBeDisabled(); + }); + + it('uses PAT instructions instead of a cloud link for Azure DevOps Server', () => { + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Using Azure DevOps Server?' }), + ); + fireEvent.change(screen.getByLabelText(/Azure DevOps Base URL/), { + target: { value: 'https://ado.example.com/tfs' }, + }); + fireEvent.change(screen.getByLabelText('Azure DevOps Organization'), { + target: { value: 'Default Collection' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect( + screen.getByRole('link', { name: 'View PAT setup instructions' }), + ).toHaveAttribute( + 'href', + 'https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops', + ); + expect( + screen.queryByRole('link', { name: 'Create Azure DevOps PAT' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText(/Azure DevOps Base URL/), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: 'Show advanced options' }), + ); + expect(screen.getByLabelText(/Azure DevOps Base URL/)).toHaveValue( + 'https://ado.example.com/tfs', + ); + + fireEvent.change(screen.getByLabelText('Azure DevOps Access Token'), { + target: { value: 'ado-pat' }, + }); + expect( + screen.getByRole('button', { name: 'Save and continue' }), + ).toBeEnabled(); + + fireEvent.change(screen.getByLabelText(/Azure DevOps Base URL/), { + target: { value: 'ado.example.com' }, + }); + fireEvent.click( + screen.getByRole('button', { name: 'Hide advanced options' }), + ); + expect( + screen.getByText('Enter a valid HTTP or HTTPS Azure DevOps Server URL.'), + ).toBeVisible(); + expect( + screen.getByRole('button', { name: 'Show advanced options' }), + ).toHaveAttribute('aria-describedby', 'setup-ado-advanced-options-error'); + expect( + screen.getByRole('button', { name: 'Save and continue' }), + ).toBeDisabled(); + + fireEvent.click( + screen.getByRole('button', { name: 'Show advanced options' }), + ); + fireEvent.change(screen.getByLabelText(/Azure DevOps Base URL/), { + target: { value: '' }, + }); + expect( + screen.getByText(/Use only letters, numbers, and hyphens/), + ).toBeVisible(); + expect( + screen.getByRole('button', { name: 'Save and link account' }), + ).toBeDisabled(); + }); + + it('links to PAT creation for the entered organization and can go back', () => { + const onBack = vi.fn(); + + render( + , + ); + + fireEvent.change(screen.getByLabelText('Azure DevOps Organization'), { + target: { value: ' acme ' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect( + screen.getByRole('link', { name: 'Create Azure DevOps PAT' }), + ).toHaveAttribute( + 'href', + 'https://dev.azure.com/acme/_usersSettings/tokens', + ); + expect( + screen.getByLabelText('Azure DevOps Access Token'), + ).toBeInTheDocument(); + expect(screen.getByText('acme')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(screen.getByLabelText('Azure DevOps Organization')).toHaveValue( + 'acme', + ); + expect(onBack).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: 'saved', + status: buildSetupSourceControlStatus({ + selectedProvider: 'ado', + persistedEnvVarNames: ['ADO_ORGANIZATION'], + persistedEnvVarValues: { ADO_ORGANIZATION: 'saved-org' }, + }), + organization: 'saved-org', + }, + { + name: 'runtime', + status: buildSetupSourceControlStatus({ + selectedProvider: 'ado', + runtimeEnv: { ADO_ORGANIZATION: 'runtime-org' }, + }), + organization: 'runtime-org', + }, + ])( + 'uses a $name organization for the PAT link', + ({ name, status, organization }) => { + render( + , + ); + + expect( + screen.getByRole('link', { name: 'Create Azure DevOps PAT' }), + ).toHaveAttribute( + 'href', + `https://dev.azure.com/${organization}/_usersSettings/tokens`, + ); + + if (name === 'runtime') { + expect( + screen.queryByRole('button', { name: 'Change organization' }), + ).not.toBeInTheDocument(); + expect( + screen.getByText('Managed by runtime configuration'), + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Back' }), + ).not.toBeInTheDocument(); + } + }, + ); + + it('separates Azure DevOps account linking from advanced options', () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText('Azure DevOps Organization'), { + target: { value: 'acme' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect( + screen.queryByLabelText(/Azure DevOps Base URL/), + ).not.toBeInTheDocument(); + expect(screen.getByText('Microsoft Entra Client ID')).toBeVisible(); + + fireEvent.click( + screen.getByRole('button', { name: 'Show advanced options' }), + ); + + expect(screen.getByText('Azure DevOps Base URL (optional)')).toBeVisible(); + expect(screen.getByText('Azure DevOps Username (optional)')).toBeVisible(); + expect(screen.getByText('Microsoft Entra Client ID')).toBeVisible(); + expect( + screen.getByText('Azure DevOps Webhook Secret (optional)'), + ).toBeVisible(); + + expect(screen.getByText('Microsoft Entra Client ID')).toBeVisible(); + expect(screen.getByText('Microsoft Entra Client Secret')).toBeVisible(); + expect( + screen.getByText( + 'Microsoft Entra Tenant ID (required unless multi-tenant)', + ), + ).toBeVisible(); + expect( + screen.getByText(/tenant ID is required unless the app supports/), + ).toBeVisible(); + expect( + screen.getByText(/If Microsoft Teams or Microsoft sign-in/), + ).toBeVisible(); + expect( + screen.getByText(/\/api\/auth\/oauth2\/callback\/ado$/), + ).toBeVisible(); + }); + + it('requires OAuth setup and saves it with the organization and PAT', () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText('Azure DevOps Organization'), { + target: { value: ' acme ' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + fireEvent.change(screen.getByLabelText('Azure DevOps Access Token'), { + target: { value: 'ado-pat' }, + }); + expect( + screen.getByRole('button', { name: 'Save and link account' }), + ).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Microsoft Entra Client ID'), { + target: { value: 'client-id' }, + }); + fireEvent.change(screen.getByLabelText('Microsoft Entra Client Secret'), { + target: { value: 'client-secret' }, + }); + fireEvent.click( + screen.getByRole('button', { name: 'Save and link account' }), + ); + + expect(mutateAsyncMock).toHaveBeenCalledWith({ + provider: 'ado', + values: { + ADO_CLIENT_ID: 'client-id', + ADO_CLIENT_SECRET: 'client-secret', + ADO_ORGANIZATION: 'acme', + ADO_TOKEN: 'ado-pat', + }, + }); + }); }); diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx index 0b46311f..3a025851 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConfig.tsx @@ -10,6 +10,13 @@ import type { } from '@roomote/types'; import { useTRPC } from '@/trpc/client'; +import { + AdoSourceControlConfigFields, + getAdoOAuthValidationError, + getEffectiveAdoBaseUrl, + getEffectiveAdoOrganization, + isAdoOAuthReady, +} from '@/components/source-control/AdoSourceControlConfigFields'; import { ArrowLeft, ArrowRight, @@ -24,6 +31,15 @@ import { Spinner, } from '@/components/system'; import { useCreateGitHubAppManifest } from '@/hooks/github'; +import { + useAdoLinkedAccount, + useAuthenticateAdoAccount, +} from '@/hooks/linked-accounts'; +import { + getAdoBaseUrlValidationError, + getAdoOrganizationValidationError, + isAdoCloudBaseUrl, +} from '@/lib/ado'; import { StepTitle } from './StepTitle'; import { getSourceControlSetupCopy } from './sourceControlSetupCopy'; @@ -85,6 +101,14 @@ export function StepSourceControlConfig({ >({}); const [showManualGitHubValues, setShowManualGitHubValues] = useState(false); const [githubOrganization, setGithubOrganization] = useState(''); + const [adoOrganizationConfirmed, setAdoOrganizationConfirmed] = + useState(false); + const [adoAdvancedExpanded, setAdoAdvancedExpanded] = useState(false); + const adoPostSaveActionRef = useRef<'continue' | 'link'>('continue'); + const adoLinkedAccount = useAdoLinkedAccount({ + enabled: effectiveSelectedProviderId === 'ado', + }); + const authenticateAdoAccount = useAuthenticateAdoAccount(); const [manifestForm, setManifestForm] = useState(null); const manifestFormRef = useRef(null); @@ -94,7 +118,12 @@ export function StepSourceControlConfig({ await queryClient.invalidateQueries({ queryKey: trpc.setupNew.status.queryKey(), }); - onContinue(); + if (adoPostSaveActionRef.current === 'link') { + adoPostSaveActionRef.current = 'continue'; + authenticateAdoAccount.mutate('/setup?step=source-control-connect'); + } else { + onContinue(); + } }, onError: (error) => { toast.error(error.message); @@ -139,14 +168,27 @@ export function StepSourceControlConfig({ const [values, setValues] = useState>( () => nonSecretInitialValues, ); + const hasConfiguredAdoOrganization = + selectedProvider?.provider === 'ado' && + selectedProvider.fields.some( + (field) => + field.envVarName === 'ADO_ORGANIZATION' && + (field.runtimeSatisfied || field.savedSatisfied), + ); useEffect(() => { setValues(nonSecretInitialValues); setEditingSavedValues({}); setShowManualGitHubValues(false); setGithubOrganization(''); + setAdoOrganizationConfirmed(hasConfiguredAdoOrganization); + setAdoAdvancedExpanded(false); setManifestForm(null); - }, [effectiveSelectedProviderId, nonSecretInitialValues]); + }, [ + effectiveSelectedProviderId, + hasConfiguredAdoOrganization, + nonSecretInitialValues, + ]); useEffect(() => { if (manifestForm) { @@ -161,7 +203,7 @@ export function StepSourceControlConfig({ field.savedSatisfied, ) ?? false; - const isActionDisabled = + const isSaveActionDisabled = saveSourceControlConfig.isPending || !selectedProvider || selectedProvider.fields.some((field) => { @@ -194,11 +236,35 @@ export function StepSourceControlConfig({ const isGitHubManifestDefault = selectedProvider?.provider === 'github' && !showManualGitHubValues; const isGitLab = selectedProvider?.provider === 'gitlab'; + const isAdo = selectedProvider?.provider === 'ado'; + const adoOrganization = isAdo + ? getEffectiveAdoOrganization(selectedProvider.fields, values) + : ''; + const adoBaseUrl = isAdo + ? getEffectiveAdoBaseUrl(selectedProvider.fields, values) + : ''; + const adoOrganizationValidationError = getAdoOrganizationValidationError( + adoOrganization, + adoBaseUrl, + ); + const adoBaseUrlValidationError = getAdoBaseUrlValidationError(adoBaseUrl); + const usesAdoCloud = isAdo && isAdoCloudBaseUrl(adoBaseUrl); + const adoOAuthValidationError = usesAdoCloud + ? getAdoOAuthValidationError(selectedProvider.fields, values) + : null; + const adoOAuthReady = usesAdoCloud + ? isAdoOAuthReady(selectedProvider.fields, values) + : true; + const adoAccountLinked = Boolean(adoLinkedAccount.data?.account); + const canConfirmAdoOrganization = + adoOrganizationValidationError === null && + adoBaseUrlValidationError === null; const publicOrigin = typeof window === 'undefined' ? 'https://your-deployment-url' : window.location.origin; const gitlabRedirectUri = `${publicOrigin}/api/auth/oauth2/callback/gitlab`; + const adoRedirectUri = `${publicOrigin}/api/auth/oauth2/callback/ado`; const typedGitLabBaseUrl = values['GITLAB_BASE_URL']?.trim().replace(/\/+$/, '') ?? ''; const configuredGitLabBaseUrl = @@ -312,6 +378,134 @@ export function StepSourceControlConfig({ ); } + if (isAdo && selectedProvider) { + const isAdoOrganizationRuntimeManaged = + selectedProvider.fields.find( + (field) => field.envVarName === 'ADO_ORGANIZATION', + )?.runtimeSatisfied === true; + const handleAdoAction = () => { + if (!adoOrganizationConfirmed) { + if (!canConfirmAdoOrganization) { + return; + } + + const organizationField = selectedProvider.fields.find( + (field) => field.envVarName === 'ADO_ORGANIZATION', + ); + + if (!organizationField?.runtimeSatisfied) { + setValues((current) => ({ + ...current, + ADO_ORGANIZATION: adoOrganization, + })); + } + setAdoAdvancedExpanded(false); + setAdoOrganizationConfirmed(true); + return; + } + + adoPostSaveActionRef.current = + usesAdoCloud && !adoAccountLinked ? 'link' : 'continue'; + void handleContinue(); + }; + const isAdoActionDisabled = adoOrganizationConfirmed + ? isSaveActionDisabled || + authenticateAdoAccount.isPending || + !adoOAuthReady || + adoOrganizationValidationError !== null || + adoBaseUrlValidationError !== null || + adoOAuthValidationError !== null + : saveSourceControlConfig.isPending || !canConfirmAdoOrganization; + + return ( +
+ + + + setValues((current) => ({ + ...current, + [envVarName]: value, + })) + } + onEditingSavedValueChange={(envVarName, editing) => + setEditingSavedValues((current) => ({ + ...current, + [envVarName]: editing, + })) + } + onEditOrganization={() => { + setAdoAdvancedExpanded(false); + setAdoOrganizationConfirmed(false); + }} + onAdvancedExpandedChange={setAdoAdvancedExpanded} + /> + +
+ {onBack || + (adoOrganizationConfirmed && !isAdoOrganizationRuntimeManaged) ? ( + + ) : null} + +
+
+ ); + } + return (
@@ -505,7 +699,7 @@ export function StepSourceControlConfig({
diff --git a/apps/web/src/components/source-control/AdoSourceControlConfigFields.tsx b/apps/web/src/components/source-control/AdoSourceControlConfigFields.tsx new file mode 100644 index 00000000..493c7649 --- /dev/null +++ b/apps/web/src/components/source-control/AdoSourceControlConfigFields.tsx @@ -0,0 +1,548 @@ +'use client'; + +import type { SetupSourceControlStatus } from '@roomote/types'; + +import { + Button, + Check, + ChevronDown, + CopyIconButton, + ExternalLink, + Input, + Label, + Spinner, +} from '@/components/system'; +import { + ADO_PAT_DOCUMENTATION_URL, + buildAdoPersonalAccessTokenUrl, + getAdoBaseUrlValidationError, + getAdoOrganizationValidationError, + isAdoCloudBaseUrl, + normalizeAdoOrganization, +} from '@/lib/ado'; + +const MASKED_VALUE = '••••••••••••••••••••••••••••'; +const ADO_ORGANIZATION_ENV_VAR = 'ADO_ORGANIZATION'; +const ADO_TOKEN_ENV_VAR = 'ADO_TOKEN'; +const ADO_BASE_URL_ENV_VAR = 'ADO_BASE_URL'; +const ADO_CLIENT_ID_ENV_VAR = 'ADO_CLIENT_ID'; +const ADO_CLIENT_SECRET_ENV_VAR = 'ADO_CLIENT_SECRET'; +const ADO_TENANT_ID_ENV_VAR = 'ADO_TENANT_ID'; +const ADO_OAUTH_ENV_VARS = new Set([ + ADO_CLIENT_ID_ENV_VAR, + ADO_CLIENT_SECRET_ENV_VAR, + ADO_TENANT_ID_ENV_VAR, +]); +const ENTRA_APP_REGISTRATIONS_URL = + 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade'; + +type SourceControlField = + SetupSourceControlStatus['providers'][number]['fields'][number]; + +function isSecretSourceControlField(field: Pick) { + return field.secret === true; +} + +function fieldInputId(idPrefix: string, field: SourceControlField) { + return `${idPrefix}-${field.envVarName.toLowerCase().replaceAll('_', '-')}`; +} + +export function getEffectiveAdoOrganization( + fields: readonly SourceControlField[], + values: Record, +): string { + const organizationField = fields.find( + (field) => field.envVarName === ADO_ORGANIZATION_ENV_VAR, + ); + const organization = + values[ADO_ORGANIZATION_ENV_VAR] ?? organizationField?.savedValue ?? ''; + + return normalizeAdoOrganization(organization); +} + +export function getEffectiveAdoBaseUrl( + fields: readonly SourceControlField[], + values: Record, +): string { + const baseUrlField = fields.find( + (field) => field.envVarName === ADO_BASE_URL_ENV_VAR, + ); + + return ( + values[ADO_BASE_URL_ENV_VAR] ?? + baseUrlField?.savedValue ?? + '' + ).trim(); +} + +function isFieldSatisfied( + field: SourceControlField | undefined, + values: Record, +) { + return ( + field?.runtimeSatisfied === true || + field?.savedSatisfied === true || + (field ? (values[field.envVarName]?.trim().length ?? 0) > 0 : false) + ); +} + +export function getAdoOAuthValidationError( + fields: readonly SourceControlField[], + values: Record, +): string | null { + const clientIdSatisfied = isFieldSatisfied( + fields.find((field) => field.envVarName === ADO_CLIENT_ID_ENV_VAR), + values, + ); + const clientSecretSatisfied = isFieldSatisfied( + fields.find((field) => field.envVarName === ADO_CLIENT_SECRET_ENV_VAR), + values, + ); + + if (!clientIdSatisfied && !clientSecretSatisfied) { + return 'Enter the Microsoft Entra application (client) ID and client secret to continue.'; + } + + return clientIdSatisfied !== clientSecretSatisfied + ? 'Enter both the Microsoft Entra application (client) ID and client secret.' + : null; +} + +export function isAdoOAuthReady( + fields: readonly SourceControlField[], + values: Record, +) { + return [ADO_CLIENT_ID_ENV_VAR, ADO_CLIENT_SECRET_ENV_VAR].every( + (envVarName) => + isFieldSatisfied( + fields.find((field) => field.envVarName === envVarName), + values, + ), + ); +} + +export function AdoSourceControlConfigFields({ + fields, + values, + editingSavedValues, + organizationConfirmed, + advancedExpanded, + oauthCallbackUrl, + oauthAccountLinked, + oauthAccountStatePending, + disabled, + compact = false, + idPrefix, + onValueChange, + onEditingSavedValueChange, + onEditOrganization, + onAdvancedExpandedChange, +}: { + fields: readonly SourceControlField[]; + values: Record; + editingSavedValues: Record; + organizationConfirmed: boolean; + advancedExpanded: boolean; + oauthCallbackUrl: string; + oauthAccountLinked: boolean; + oauthAccountStatePending: boolean; + disabled: boolean; + compact?: boolean; + idPrefix: string; + onValueChange: (envVarName: string, value: string) => void; + onEditingSavedValueChange: (envVarName: string, editing: boolean) => void; + onEditOrganization: () => void; + onAdvancedExpandedChange: (expanded: boolean) => void; +}) { + const organizationField = fields.find( + (field) => field.envVarName === ADO_ORGANIZATION_ENV_VAR, + ); + const tokenField = fields.find( + (field) => field.envVarName === ADO_TOKEN_ENV_VAR, + ); + const baseUrlField = fields.find( + (field) => field.envVarName === ADO_BASE_URL_ENV_VAR, + ); + const oauthFields = fields.filter((field) => + ADO_OAUTH_ENV_VARS.has(field.envVarName), + ); + const reusesMicrosoftApp = [ + ADO_CLIENT_ID_ENV_VAR, + ADO_CLIENT_SECRET_ENV_VAR, + ].every((envVarName) => + oauthFields + .find((field) => field.envVarName === envVarName) + ?.satisfiedByEnvVarName?.startsWith('R_MICROSOFT_'), + ); + const advancedFields = fields.filter( + (field) => + field.envVarName !== ADO_ORGANIZATION_ENV_VAR && + field.envVarName !== ADO_TOKEN_ENV_VAR && + !ADO_OAUTH_ENV_VARS.has(field.envVarName), + ); + const organization = getEffectiveAdoOrganization(fields, values); + const baseUrl = getEffectiveAdoBaseUrl(fields, values); + const usesAdoCloud = isAdoCloudBaseUrl(baseUrl); + const organizationValidationError = getAdoOrganizationValidationError( + organization, + baseUrl, + ); + const visibleOrganizationValidationError = organization + ? organizationValidationError + : null; + const baseUrlValidationError = getAdoBaseUrlValidationError(baseUrl); + const patCreationUrl = usesAdoCloud + ? buildAdoPersonalAccessTokenUrl(organization) + : ADO_PAT_DOCUMENTATION_URL; + const advancedContentId = `${idPrefix}-advanced-options`; + const serverContentId = `${idPrefix}-server-options`; + const serverCollapsedErrorId = `${idPrefix}-server-options-error`; + const advancedCollapsedErrorId = `${idPrefix}-advanced-options-error`; + const oauthValidationError = getAdoOAuthValidationError(fields, values); + + const renderField = ( + field: SourceControlField, + validationError: string | null = null, + qualifier: + | 'optional' + | 'required-unless-multi-tenant' + | null = field.required === false ? 'optional' : null, + ) => { + const value = values[field.envVarName] ?? ''; + const isSecretField = isSecretSourceControlField(field); + const shouldShowSavedValueMask = + isSecretField && + !field.runtimeSatisfied && + field.savedSatisfied && + value.length === 0 && + !editingSavedValues[field.envVarName]; + const inputId = fieldInputId(idPrefix, field); + const errorId = `${inputId}-error`; + + return ( +
+ +
+
+ { + if (shouldShowSavedValueMask) { + onEditingSavedValueChange(field.envVarName, true); + } + }} + onBlur={() => { + if ( + isSecretField && + field.savedSatisfied && + value.length === 0 + ) { + onEditingSavedValueChange(field.envVarName, false); + } + }} + onChange={(event) => + onValueChange(field.envVarName, event.target.value) + } + placeholder={field.runtimeSatisfied ? '' : field.envVarName} + disabled={disabled || field.runtimeSatisfied} + aria-invalid={validationError ? true : undefined} + aria-describedby={validationError ? errorId : undefined} + data-1p-ignore + /> + {(field.runtimeSatisfied || field.savedSatisfied) && } +
+ {validationError ? ( +

+ {validationError} +

+ ) : null} +
+
+ ); + }; + + if (!organizationConfirmed) { + return ( +
+
+

+ {usesAdoCloud + ? 'Enter your Azure DevOps organization.' + : 'Enter your Azure DevOps Server project collection.'} +

+

+ {usesAdoCloud ? ( + <> + Use the organization name from your{' '} + https://dev.azure.com/<organization> URL. + + ) : ( + 'Use the collection name shown in your Azure DevOps Server web portal URL.' + )} +

+
+ {organizationField + ? renderField(organizationField, visibleOrganizationValidationError) + : null} + {baseUrlField ? ( +
+ + {advancedExpanded ? ( +
+

+ Enter the web portal URL for your self-hosted Azure DevOps + Server. The organization field should contain its project + collection name. +

+ {renderField(baseUrlField, baseUrlValidationError)} +
+ ) : baseUrlValidationError ? ( +

+ {baseUrlValidationError} +

+ ) : null} +
+ ) : null} +
+ ); + } + + return ( +
+
+
+ Organization + {organization} + {organizationField?.runtimeSatisfied ? ( + + Managed by runtime configuration + + ) : ( + + )} +
+ {organizationValidationError ? ( +

+ {organizationValidationError} +

+ ) : null} +
+ +
+
+

+ {usesAdoCloud + ? 'Create a personal access token.' + : 'Create a personal access token in Azure DevOps Server.'} +

+

+ {usesAdoCloud + ? 'Create the PAT with Code read & write access and permission to manage service hook subscriptions, then paste it below.' + : 'Open the PAT instructions, then create a token from your server user settings and paste it below.'} +

+
+ {patCreationUrl ? ( + + ) : null} +
+ + {tokenField ? renderField(tokenField) : null} + + {usesAdoCloud && oauthFields.length > 0 ? ( +
+
+

Set up user account linking

+

+ The PAT connects this Roomote deployment to Azure DevOps. OAuth + separately identifies each user. Both are required to finish Azure + DevOps setup and let users start Roomote work from pull request + comments. +

+
+
+
+ {reusesMicrosoftApp ? ( +

+ Roomote found your existing Microsoft setup and will reuse its + Entra app credentials. You only need to add the callback and + Azure DevOps permission below, then link your account. +

+ ) : null} +

+ Create a Microsoft Entra app registration. If Microsoft Teams or + Microsoft sign-in is already configured, you can reuse that app: + add the callback below and grant the delegated Azure DevOps{' '} + user_impersonation permission. +

+ +

Configure this Web redirect URI:

+

+ + {oauthCallbackUrl} + + +

+

+ Paste the application ID and client secret below. The tenant ID + is required unless the app supports multiple tenants. If you + reuse the Teams app, use its tenant ID. +

+
+
+ {oauthFields.map((field) => + renderField( + field, + null, + field.envVarName === ADO_TENANT_ID_ENV_VAR + ? 'required-unless-multi-tenant' + : null, + ), + )} +
+ {oauthValidationError ? ( +

+ {oauthValidationError} +

+ ) : null} + {oauthAccountStatePending ? ( +

+ Checking linked account… +

+ ) : oauthAccountLinked ? ( +

+ Your Azure DevOps account is linked. +

+ ) : null} +
+
+ ) : null} + + {advancedFields.length > 0 ? ( +
+ + {advancedExpanded ? ( +
+

+ Optional settings for custom hosts, Git credentials, and webhook + verification. +

+ {advancedFields.map((field) => + renderField( + field, + field.envVarName === ADO_BASE_URL_ENV_VAR + ? baseUrlValidationError + : null, + ), + )} +
+ ) : baseUrlValidationError ? ( +

+ {baseUrlValidationError} +

+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/hooks/linked-accounts/useAdoLinkedAccount.ts b/apps/web/src/hooks/linked-accounts/useAdoLinkedAccount.ts index af03219a..605b1dc1 100644 --- a/apps/web/src/hooks/linked-accounts/useAdoLinkedAccount.ts +++ b/apps/web/src/hooks/linked-accounts/useAdoLinkedAccount.ts @@ -2,8 +2,12 @@ import { useQuery } from '@tanstack/react-query'; import { useTRPC } from '@/trpc/client'; -export const useAdoLinkedAccount = () => { +export const useAdoLinkedAccount = (options?: { enabled?: boolean }) => { const trpc = useTRPC(); - return useQuery(trpc.linkedAccounts.ado.queryOptions()); + return useQuery( + trpc.linkedAccounts.ado.queryOptions(undefined, { + enabled: options?.enabled ?? true, + }), + ); }; diff --git a/apps/web/src/lib/ado.test.ts b/apps/web/src/lib/ado.test.ts new file mode 100644 index 00000000..6dd48881 --- /dev/null +++ b/apps/web/src/lib/ado.test.ts @@ -0,0 +1,78 @@ +import { + buildAdoPersonalAccessTokenUrl, + getAdoBaseUrlValidationError, + getAdoOrganizationValidationError, + isAdoCloudBaseUrl, + normalizeAdoOrganization, +} from './ado'; + +describe('Azure DevOps setup helpers', () => { + it('normalizes an organization slug', () => { + expect(normalizeAdoOrganization(' /acme/ ')).toBe('acme'); + }); + + it('builds an organization-scoped PAT URL', () => { + expect(buildAdoPersonalAccessTokenUrl('Acme-Org')).toBe( + 'https://dev.azure.com/Acme-Org/_usersSettings/tokens', + ); + }); + + it.each([ + '', + ' ', + '.', + '..', + 'acme org', + 'acme/org', + 'https://dev.azure.com/acme', + '-acme', + 'acme-', + 'a'.repeat(50), + ])('rejects an unsafe organization %j', (value) => { + expect(buildAdoPersonalAccessTokenUrl(value)).toBeNull(); + }); + + it('accepts Azure DevOps Server collection names only with a custom host', () => { + expect( + getAdoOrganizationValidationError( + 'Default Collection', + 'https://ado.example.com/tfs', + ), + ).toBeNull(); + expect( + getAdoOrganizationValidationError( + 'Default Collection', + 'https://dev.azure.com', + ), + ).not.toBeNull(); + expect( + getAdoOrganizationValidationError( + 'collection/name', + 'https://ado.example.com/tfs', + ), + ).not.toBeNull(); + }); + + it('recognizes only the default cloud URL as Azure DevOps Services', () => { + expect(isAdoCloudBaseUrl('')).toBe(true); + expect(isAdoCloudBaseUrl('https://dev.azure.com/')).toBe(true); + expect(isAdoCloudBaseUrl('https://dev.azure.com:444')).toBe(false); + expect(isAdoCloudBaseUrl('https://ado.example.com/tfs')).toBe(false); + }); + + it('validates custom Azure DevOps Server URLs', () => { + expect( + getAdoBaseUrlValidationError('https://ado.example.com/tfs'), + ).toBeNull(); + expect(getAdoBaseUrlValidationError('ado.example.com')).not.toBeNull(); + expect( + getAdoBaseUrlValidationError('ftp://ado.example.com'), + ).not.toBeNull(); + expect( + getAdoBaseUrlValidationError('https://ado.example.com/tfs?view=mine'), + ).not.toBeNull(); + expect( + getAdoBaseUrlValidationError('https://ado.example.com/tfs#tokens'), + ).not.toBeNull(); + }); +}); diff --git a/apps/web/src/lib/ado.ts b/apps/web/src/lib/ado.ts new file mode 100644 index 00000000..a78eaa49 --- /dev/null +++ b/apps/web/src/lib/ado.ts @@ -0,0 +1,105 @@ +export const ADO_CLOUD_BASE_URL = 'https://dev.azure.com'; +export const ADO_PAT_DOCUMENTATION_URL = + 'https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops'; + +const ADO_CLOUD_ORGANIZATION_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,47}[A-Za-z0-9])?$/; +const ADO_SERVER_COLLECTION_FORBIDDEN_CHARACTERS = '\\/:*?"<>;#${},+=[]|'; + +export function normalizeAdoOrganization(organization: string): string { + return organization.trim().replace(/^\/+|\/+$/g, ''); +} + +export function getAdoBaseUrlValidationError(baseUrl: string): string | null { + const normalizedBaseUrl = baseUrl.trim(); + + if (!normalizedBaseUrl) { + return null; + } + + try { + const url = new URL(normalizedBaseUrl); + + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + return 'Enter a valid HTTP or HTTPS Azure DevOps Server URL.'; + } + } catch { + return 'Enter a valid HTTP or HTTPS Azure DevOps Server URL.'; + } + + return null; +} + +export function isAdoCloudBaseUrl(baseUrl: string): boolean { + const normalizedBaseUrl = baseUrl.trim(); + + if (!normalizedBaseUrl) { + return true; + } + + try { + const url = new URL(normalizedBaseUrl); + return ( + url.origin === ADO_CLOUD_BASE_URL && + (url.pathname === '/' || url.pathname === '') && + !url.search && + !url.hash && + !url.username && + !url.password + ); + } catch { + return false; + } +} + +export function getAdoOrganizationValidationError( + organization: string, + baseUrl = '', +): string | null { + const normalizedOrganization = normalizeAdoOrganization(organization); + + if (!normalizedOrganization) { + return 'Enter your Azure DevOps organization.'; + } + + if (isAdoCloudBaseUrl(baseUrl)) { + return ADO_CLOUD_ORGANIZATION_PATTERN.test(normalizedOrganization) + ? null + : 'Use only letters, numbers, and hyphens, start and end with a letter or number, and use fewer than 50 characters.'; + } + + if ( + normalizedOrganization.length > 64 || + Array.from(normalizedOrganization).some( + (character) => + character.charCodeAt(0) < 32 || + ADO_SERVER_COLLECTION_FORBIDDEN_CHARACTERS.includes(character), + ) || + normalizedOrganization.startsWith('_') || + normalizedOrganization.startsWith('.') || + normalizedOrganization.endsWith('.') || + normalizedOrganization.includes('..') + ) { + return 'Enter a valid Azure DevOps Server project collection name.'; + } + + return null; +} + +export function buildAdoPersonalAccessTokenUrl( + organization: string, +): string | null { + const normalizedOrganization = normalizeAdoOrganization(organization); + + if (getAdoOrganizationValidationError(normalizedOrganization) !== null) { + return null; + } + + return `${ADO_CLOUD_BASE_URL}/${normalizedOrganization}/_usersSettings/tokens`; +} diff --git a/apps/web/src/lib/server/auth-provider-config.client.test.ts b/apps/web/src/lib/server/auth-provider-config.client.test.ts index 150dbaef..5194ab54 100644 --- a/apps/web/src/lib/server/auth-provider-config.client.test.ts +++ b/apps/web/src/lib/server/auth-provider-config.client.test.ts @@ -129,19 +129,21 @@ describe('resolveAuthProviderConfig', () => { expect(config.adoBaseUrl).toBe('https://dev.azure.example.com'); }); - it('does not use the Microsoft tenant for Azure DevOps Entra linking when no ADO tenant is configured', async () => { + it('reuses Microsoft credentials for Azure DevOps Entra linking when ADO credentials are not configured', async () => { const config = await resolveAuthProviderConfig({ runtimeEnv: {}, deploymentEnvVars: { + R_MICROSOFT_CLIENT_ID: 'microsoft-client-id', + R_MICROSOFT_CLIENT_SECRET: 'microsoft-client-secret', R_MICROSOFT_TENANT_ID: 'microsoft-tenant-id', - ADO_CLIENT_ID: 'ado-client-id', - ADO_CLIENT_SECRET: 'ado-client-secret', ADO_ORGANIZATION: 'ado-org', }, }); - expect(config.enabledProviders).toEqual([]); - expect(config.adoTenantId).toBeNull(); + expect(config.enabledProviders).toEqual(['microsoft']); + expect(config.adoClientId).toBe('microsoft-client-id'); + expect(config.adoClientSecret).toBe('microsoft-client-secret'); + expect(config.adoTenantId).toBe('microsoft-tenant-id'); }); it('bootstraps the web runtime before resolving default deployment env vars', async () => { diff --git a/apps/web/src/lib/server/auth-provider-config.ts b/apps/web/src/lib/server/auth-provider-config.ts index b4c623f7..0f72700b 100644 --- a/apps/web/src/lib/server/auth-provider-config.ts +++ b/apps/web/src/lib/server/auth-provider-config.ts @@ -130,12 +130,14 @@ export async function resolveAuthProviderConfig( readConfiguredValue(runtimeEnv, deploymentEnvVars, 'BITBUCKET_BASE_URL') ?? null; const adoClientId = - readConfiguredValue(runtimeEnv, deploymentEnvVars, 'ADO_CLIENT_ID') ?? null; + readConfiguredValue(runtimeEnv, deploymentEnvVars, 'ADO_CLIENT_ID') ?? + microsoftClientId; const adoClientSecret = readConfiguredValue(runtimeEnv, deploymentEnvVars, 'ADO_CLIENT_SECRET') ?? - null; + microsoftClientSecret; const adoTenantId = - readConfiguredValue(runtimeEnv, deploymentEnvVars, 'ADO_TENANT_ID') ?? null; + readConfiguredValue(runtimeEnv, deploymentEnvVars, 'ADO_TENANT_ID') ?? + microsoftTenantId; const adoOrganization = readConfiguredValue(runtimeEnv, deploymentEnvVars, 'ADO_ORGANIZATION') ?? null; diff --git a/apps/web/src/trpc/commands/environment-variables/index.test.ts b/apps/web/src/trpc/commands/environment-variables/index.test.ts index cf53ad17..73e8c132 100644 --- a/apps/web/src/trpc/commands/environment-variables/index.test.ts +++ b/apps/web/src/trpc/commands/environment-variables/index.test.ts @@ -2,8 +2,9 @@ import type { FeatureFlag } from '@roomote/feature-flags'; import type { UserAuthSuccess } from '@/types'; -const { mockFinalChain } = vi.hoisted(() => ({ +const { mockFinalChain, mockInArray } = vi.hoisted(() => ({ mockFinalChain: vi.fn(), + mockInArray: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ @@ -19,12 +20,16 @@ vi.mock('@roomote/db/server', () => ({ environmentVariables: { name: 'env.name', userId: 'env.user_id' }, eq: vi.fn(), desc: vi.fn(), - inArray: vi.fn(), + inArray: mockInArray, not: vi.fn(), getTableColumns: () => ({ id: 'env.id', name: 'env.name' }), })); -import { createEnvVarCommand, getEnvVarsCommand } from './index'; +import { + createEnvVarCommand, + deleteDeploymentEnvironmentVariables, + getEnvVarsCommand, +} from './index'; function buildMockAuth( overrides: Partial = {}, @@ -72,6 +77,27 @@ describe('environment-variables commands', () => { }); }); + describe('deleteDeploymentEnvironmentVariables', () => { + it('deletes each named deployment value once', async () => { + const where = vi.fn().mockResolvedValue(undefined); + const executor = { + delete: vi.fn(() => ({ where })), + } as unknown as Parameters< + typeof deleteDeploymentEnvironmentVariables + >[0]; + + await deleteDeploymentEnvironmentVariables(executor, [ + ' ADO_BASE_URL ', + 'ADO_BASE_URL', + '', + ]); + + expect(mockInArray).toHaveBeenCalledWith('env.name', ['ADO_BASE_URL']); + expect(executor.delete).toHaveBeenCalledTimes(1); + expect(where).toHaveBeenCalledTimes(1); + }); + }); + describe('createEnvVarCommand', () => { it('rejects reserved comms provider variable names', async () => { await expect( diff --git a/apps/web/src/trpc/commands/environment-variables/index.ts b/apps/web/src/trpc/commands/environment-variables/index.ts index d6dcb37c..f0c32969 100644 --- a/apps/web/src/trpc/commands/environment-variables/index.ts +++ b/apps/web/src/trpc/commands/environment-variables/index.ts @@ -148,6 +148,23 @@ export async function upsertDeploymentEnvironmentVariables( } } +export async function deleteDeploymentEnvironmentVariables( + tx: DatabaseOrTransaction, + names: string[], +) { + const uniqueNames = Array.from( + new Set(names.map((name) => name.trim()).filter(Boolean)), + ); + + if (uniqueNames.length === 0) { + return; + } + + await tx + .delete(environmentVariables) + .where(inArray(environmentVariables.name, uniqueNames)); +} + export async function getEnvVarsCommand(auth: UserAuthSuccess) { assertAdmin(auth); diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index 19565e6c..88629861 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -14,6 +14,7 @@ const { mockResolveAdoOrganization, mockValidateAdoToken, mockValidateGiteaToken, + mockDeleteDeploymentEnvironmentVariables, mockEnv, } = vi.hoisted(() => ({ mockEnsureAdoServiceHooksForRepositories: vi.fn(), @@ -29,6 +30,7 @@ const { mockResolveAdoOrganization: vi.fn(), mockValidateAdoToken: vi.fn(), mockValidateGiteaToken: vi.fn(), + mockDeleteDeploymentEnvironmentVariables: vi.fn(), mockEnv: { R_APP_URL: 'https://roomote.example.com', TRPC_URL: 'http://localhost:3000/trpc', @@ -91,12 +93,15 @@ vi.mock('../environment-variables', () => ({ throw new Error('Unauthorized'); } }, + deleteDeploymentEnvironmentVariables: + mockDeleteDeploymentEnvironmentVariables, upsertDeploymentEnvironmentVariables: mockUpsertDeploymentEnvironmentVariables, })); import { assertValidSourceControlConfigInput, + saveSourceControlConfigValues, syncRepositoriesCommand, } from './index'; @@ -161,6 +166,34 @@ describe('source-control commands', () => { mockValidateAdoToken.mockResolvedValue({ status: 'valid' }); }); + it('clears a submitted blank optional source-control value', async () => { + const executor = { + select: () => ({ + from: () => + Promise.resolve([ + { name: 'ADO_ORGANIZATION' }, + { name: 'ADO_TOKEN' }, + { name: 'ADO_BASE_URL' }, + ]), + }), + } as unknown as Parameters< + typeof saveSourceControlConfigValues + >[0]['executor']; + + await saveSourceControlConfigValues({ + executor, + actorUserId: 'source-control-user', + provider: 'ado', + values: { ADO_BASE_URL: '' }, + }); + + expect(mockDeleteDeploymentEnvironmentVariables).toHaveBeenCalledWith( + executor, + ['ADO_BASE_URL'], + ); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + it('syncs Gitea repositories and configures pull request webhooks', async () => { const result = await syncRepositoriesCommand(buildMockAuth(), { provider: 'gitea', @@ -344,6 +377,23 @@ describe('source-control commands', () => { }); }); + it('validates a new Azure DevOps token against cloud when clearing a saved Server URL', async () => { + await assertValidSourceControlConfigInput({ + provider: 'ado', + values: { + ADO_ORGANIZATION: 'acme', + ADO_TOKEN: 'ado-token', + ADO_BASE_URL: '', + }, + }); + + expect(mockValidateAdoToken).toHaveBeenCalledWith({ + token: 'ado-token', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + }); + }); + it('skips Azure DevOps token validation when no organization is available', async () => { await assertValidSourceControlConfigInput({ provider: 'ado', diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index 209984e3..f1aa8707 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -27,11 +27,13 @@ import { import type { UserAuthSuccess } from '@/types'; +import { ADO_CLOUD_BASE_URL } from '@/lib/ado'; import { getRepositories } from '@/lib/server'; import { Env } from '@/lib/server/env'; import { assertAdmin, + deleteDeploymentEnvironmentVariables, getPersistedEnvironmentVariableValues, upsertDeploymentEnvironmentVariables, } from '../environment-variables'; @@ -644,6 +646,25 @@ export async function saveSourceControlConfigValues(params: { }, ]; }); + const submittedValues = params.values ?? {}; + const envVarNamesToClear = providerStatus.fields.flatMap((field) => { + const wasSubmitted = Object.prototype.hasOwnProperty.call( + submittedValues, + field.envVarName, + ); + const nextValue = submittedValues[field.envVarName]?.trim() ?? ''; + + if ( + field.required !== false || + !field.savedSatisfied || + !wasSubmitted || + nextValue + ) { + return []; + } + + return [field.envVarName]; + }); const hasMissingRequiredValue = providerStatus.fields.some((field) => { const nextValue = params.values?.[field.envVarName]?.trim() ?? ''; @@ -669,6 +690,13 @@ export async function saveSourceControlConfigValues(params: { }); } + if (envVarNamesToClear.length > 0) { + await deleteDeploymentEnvironmentVariables( + params.executor, + envVarNamesToClear, + ); + } + return providerStatus; } @@ -759,10 +787,18 @@ export async function assertValidSourceControlConfigInput(params: { return; } + const adoBaseUrlWasSubmitted = Object.prototype.hasOwnProperty.call( + params.values ?? {}, + 'ADO_BASE_URL', + ); + const submittedAdoBaseUrl = params.values?.['ADO_BASE_URL']?.trim() ?? ''; + const validation = await Ado.validateAdoToken({ token: nextAdoToken, organization: nextAdoOrganization, - baseUrl: params.values?.['ADO_BASE_URL']?.trim() || undefined, + baseUrl: adoBaseUrlWasSubmitted + ? submittedAdoBaseUrl || ADO_CLOUD_BASE_URL + : undefined, }); if (validation.status === 'invalid') { diff --git a/packages/types/src/setup-source-control-config.test.ts b/packages/types/src/setup-source-control-config.test.ts index f0cb8e1d..9692cdb8 100644 --- a/packages/types/src/setup-source-control-config.test.ts +++ b/packages/types/src/setup-source-control-config.test.ts @@ -175,7 +175,7 @@ describe('buildSetupSourceControlStatus', () => { expect(status.lockReason).toBe('runtime_env'); }); - it('does not require optional fields to satisfy config', () => { + it('requires OAuth fields for Azure DevOps Services configuration', () => { const status = buildSetupSourceControlStatus({ runtimeEnv: { ADO_ORGANIZATION: 'my-org', @@ -184,7 +184,7 @@ describe('buildSetupSourceControlStatus', () => { }); const ado = status.providers.find((p) => p.provider === 'ado'); - expect(ado).toMatchObject({ configSatisfied: true }); + expect(ado).toMatchObject({ configSatisfied: false }); const optionalBaseUrl = ado?.fields.find( (f) => f.envVarName === 'ADO_BASE_URL', ); @@ -268,6 +268,20 @@ describe('buildSetupSourceControlStatus', () => { }); }); + it('does not require OAuth fields for Azure DevOps Server configuration', () => { + const status = buildSetupSourceControlStatus({ + runtimeEnv: { + ADO_BASE_URL: 'https://ado.example.com/tfs', + ADO_ORGANIZATION: 'Default Collection', + ADO_TOKEN: 'ado-token', + }, + }); + + expect( + status.providers.find((provider) => provider.provider === 'ado'), + ).toMatchObject({ configSatisfied: true }); + }); + it('marks config unsatisfied when a required field is missing', () => { const status = buildSetupSourceControlStatus({ runtimeEnv: { ADO_ORGANIZATION: 'my-org' }, @@ -318,14 +332,48 @@ describe('buildSetupSourceControlStatus', () => { expect(status.connectedProvider).toBe('github'); }); - it('does not accept env var aliases in setup fields', () => { + it('only accepts Microsoft app aliases for Azure DevOps OAuth fields', () => { for (const provider of SETUP_SOURCE_CONTROL_PROVIDER_CATALOG) { for (const field of provider.fields) { - expect(field.acceptedEnvVarNames).toEqual([field.envVarName]); + const expectedAliases: Partial> = { + ADO_CLIENT_ID: 'R_MICROSOFT_CLIENT_ID', + ADO_CLIENT_SECRET: 'R_MICROSOFT_CLIENT_SECRET', + ADO_TENANT_ID: 'R_MICROSOFT_TENANT_ID', + }; + const alias = expectedAliases[field.envVarName]; + expect(field.acceptedEnvVarNames).toEqual( + alias ? [field.envVarName, alias] : [field.envVarName], + ); } } }); + it('recognizes existing Microsoft credentials as Azure DevOps OAuth configuration', () => { + const status = buildSetupSourceControlStatus({ + runtimeEnv: { + R_MICROSOFT_CLIENT_ID: 'microsoft-client-id', + R_MICROSOFT_CLIENT_SECRET: 'microsoft-client-secret', + R_MICROSOFT_TENANT_ID: 'microsoft-tenant-id', + }, + }); + const ado = status.providers.find( + (provider) => provider.provider === 'ado', + ); + + expect( + ado?.fields.find((field) => field.envVarName === 'ADO_CLIENT_ID'), + ).toMatchObject({ + runtimeSatisfied: true, + satisfiedByEnvVarName: 'R_MICROSOFT_CLIENT_ID', + }); + expect( + ado?.fields.find((field) => field.envVarName === 'ADO_CLIENT_SECRET'), + ).toMatchObject({ + runtimeSatisfied: true, + satisfiedByEnvVarName: 'R_MICROSOFT_CLIENT_SECRET', + }); + }); + it('reports setup satisfied by runtime env when the connected provider is runtime-configured', () => { const status = buildSetupSourceControlStatus({ runtimeEnv: { GITLAB_TOKEN: 'runtime-token' }, diff --git a/packages/types/src/setup-source-control-config.ts b/packages/types/src/setup-source-control-config.ts index fff09789..a26c243e 100644 --- a/packages/types/src/setup-source-control-config.ts +++ b/packages/types/src/setup-source-control-config.ts @@ -123,6 +123,27 @@ const DEFAULT_SETUP_SOURCE_CONTROL_PROVIDER_DESCRIPTOR: SetupSourceControlProvid (descriptor) => descriptor.provider === DEFAULT_SOURCE_CONTROL_PROVIDER, )!; +const ADO_CLOUD_REQUIRED_OAUTH_ENV_VAR_NAMES = new Set([ + 'ADO_CLIENT_ID', + 'ADO_CLIENT_SECRET', +]); + +function isAdoCloudConfiguration(fields: SetupSourceControlFieldStatus[]) { + const baseUrl = fields + .find((field) => field.envVarName === 'ADO_BASE_URL') + ?.savedValue?.trim(); + + if (!baseUrl) { + return true; + } + + try { + return new URL(baseUrl).hostname.toLowerCase() === 'dev.azure.com'; + } catch { + return false; + } +} + export function getSetupSourceControlProvider( provider: SourceControlProvider, ): SetupSourceControlProviderDescriptor { @@ -281,20 +302,23 @@ function buildProviderFields( }, { envVarName: 'ADO_CLIENT_ID', - acceptedEnvVarNames: ['ADO_CLIENT_ID'], + acceptedEnvVarNames: ['ADO_CLIENT_ID', 'R_MICROSOFT_CLIENT_ID'], label: 'Microsoft Entra Client ID', required: false, }, { envVarName: 'ADO_CLIENT_SECRET', - acceptedEnvVarNames: ['ADO_CLIENT_SECRET'], + acceptedEnvVarNames: [ + 'ADO_CLIENT_SECRET', + 'R_MICROSOFT_CLIENT_SECRET', + ], label: 'Microsoft Entra Client Secret', secret: true, required: false, }, { envVarName: 'ADO_TENANT_ID', - acceptedEnvVarNames: ['ADO_TENANT_ID'], + acceptedEnvVarNames: ['ADO_TENANT_ID', 'R_MICROSOFT_TENANT_ID'], label: 'Microsoft Entra Tenant ID', required: false, }, @@ -393,7 +417,14 @@ export function buildSetupSourceControlStatus(input: { }; }); - const requiredFields = fields.filter(isRequiredField); + const requiresAdoCloudOAuth = + descriptor.provider === 'ado' && isAdoCloudConfiguration(fields); + const requiredFields = fields.filter( + (field) => + isRequiredField(field) || + (requiresAdoCloudOAuth && + ADO_CLOUD_REQUIRED_OAUTH_ENV_VAR_NAMES.has(field.envVarName)), + ); const runtimeConfigSatisfied = requiredFields.every( (field) => field.runtimeSatisfied, );