diff --git a/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx b/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx index f639afc87..25e665a21 100644 --- a/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx +++ b/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx @@ -40,7 +40,7 @@ function decodeOAuthState( } } -import { useUser } from '@/hooks/useUser'; +import { useSetupBootstrapOpen, useUser } from '@/hooks/useUser'; import { useRedirectToSignIn } from '@/hooks/useSignInRedirect'; import { useFinishAuthenticateGitHubAccount, @@ -57,6 +57,7 @@ export default function Page() { const params = useSearchParams(); const { authStatus, isSignedIn } = useUser(); + const setupBootstrapOpen = useSetupBootstrapOpen(); const [isLoading, setIsLoading] = useState(true); const [isInstalled, setIsInstalled] = useState(false); @@ -71,9 +72,11 @@ export default function Page() { // user back to where those can be fixed instead of dead-ending on the error. const cameFromSetup = decodedCallbackState?.redirect?.startsWith('/setup') === true; - const errorReturnTarget = cameFromSetup - ? '/setup?step=source-control-config' - : '/settings'; + const completedSetupReturnTarget = + cameFromSetup && !setupBootstrapOpen ? '/settings/source-control' : null; + const errorReturnTarget = + completedSetupReturnTarget ?? + (cameFromSetup ? '/setup?step=source-control-config' : '/settings'); const navigateFromState = useCallback(() => { const encodedState = params.get('state'); @@ -87,8 +90,15 @@ export default function Page() { !redirect.startsWith('//') && !redirect.includes('://'); - router.push(isValidRedirect ? redirect : '/settings'); - }, [params, router]); + const setupCompletedRedirect = + isValidRedirect && redirect.startsWith('/setup') && !setupBootstrapOpen + ? '/settings/source-control' + : null; + + router.push( + setupCompletedRedirect ?? (isValidRedirect ? redirect : '/settings'), + ); + }, [params, router, setupBootstrapOpen]); const finishAuthentication = useFinishAuthenticateGitHubAccount({ onSuccess: (result) => { diff --git a/apps/web/src/app/(centered)/github/callback/page.client.test.tsx b/apps/web/src/app/(centered)/github/callback/page.client.test.tsx index d97ea33b7..71787e592 100644 --- a/apps/web/src/app/(centered)/github/callback/page.client.test.tsx +++ b/apps/web/src/app/(centered)/github/callback/page.client.test.tsx @@ -205,7 +205,7 @@ describe('GitHub callback page', () => { ).toBeInTheDocument(); }); - it('sends setup-originated errors back to the source-control configuration step', async () => { + it('sends setup-originated errors to settings after setup is complete', async () => { const originalLocation = window.location; const assignMock = vi.fn(); @@ -238,9 +238,7 @@ describe('GitHub callback page', () => { // Full document navigation so the setup wizard reads the ?step= deep // link on a fresh load instead of racing a client-side transition. - expect(assignMock).toHaveBeenCalledWith( - '/setup?step=source-control-config', - ); + expect(assignMock).toHaveBeenCalledWith('/settings/source-control'); expect(mockPush).not.toHaveBeenCalled(); Object.defineProperty(window, 'location', { diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx index b14e4be35..16a13404f 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx @@ -38,13 +38,13 @@ export function InstructionUrl({ url: string; }) { return ( -
-

+

+

{heading}

-
+
- + {url} diff --git a/apps/web/src/app/api/source-control/bitbucket/oauth/authorize/route.ts b/apps/web/src/app/api/source-control/bitbucket/oauth/authorize/route.ts index 7685bc7b6..1f666c24f 100644 --- a/apps/web/src/app/api/source-control/bitbucket/oauth/authorize/route.ts +++ b/apps/web/src/app/api/source-control/bitbucket/oauth/authorize/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { resolveDeploymentEnvVar } from '@roomote/db/server'; import { BITBUCKET_OAUTH_CALLBACK_PATH, @@ -7,11 +7,16 @@ import { } from '@roomote/bitbucket'; import { authorize } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; +import { + getSourceControlOAuthReturnCookieName, + normalizeSourceControlOAuthReturnTarget, + SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE, +} from '@/lib/server/source-control-oauth-redirect'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function GET() { +export async function GET(request: NextRequest) { const authResult = await authorize(); const webEnv = await bootstrapWebRuntimeEnv(); if (!authResult.success || !authResult.isAdmin) { @@ -30,12 +35,26 @@ export async function GET() { redirectUri: buildBitbucketOAuthRedirectUri(publicAppUrl), }); const response = NextResponse.redirect(url); + const returnTarget = normalizeSourceControlOAuthReturnTarget( + request.nextUrl.searchParams.get('redirectTo'), + ); response.cookies.set('roomote-bitbucket-oauth-state', state, { httpOnly: true, sameSite: 'lax', secure: publicAppUrl.startsWith('https://'), path: BITBUCKET_OAUTH_CALLBACK_PATH, - maxAge: 600, + maxAge: SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE, }); + response.cookies.set( + getSourceControlOAuthReturnCookieName('bitbucket'), + returnTarget ?? '', + { + httpOnly: true, + sameSite: 'lax', + secure: publicAppUrl.startsWith('https://'), + path: BITBUCKET_OAUTH_CALLBACK_PATH, + maxAge: SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE, + }, + ); return response; } diff --git a/apps/web/src/app/api/source-control/bitbucket/oauth/callback/route.ts b/apps/web/src/app/api/source-control/bitbucket/oauth/callback/route.ts index f6cb1163b..a7674147f 100644 --- a/apps/web/src/app/api/source-control/bitbucket/oauth/callback/route.ts +++ b/apps/web/src/app/api/source-control/bitbucket/oauth/callback/route.ts @@ -7,7 +7,14 @@ import { } from '@roomote/bitbucket'; import { authorize } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; +import { getSetupBootstrapState } from '@/lib/server/setup-bootstrap-state'; import { syncRepositoriesCommand } from '@/trpc/commands/source-control'; +import { + addSourceControlOAuthResult, + getSourceControlOAuthReturnCookieName, + isSetupOAuthReturnTarget, + resolveSourceControlOAuthReturnTarget, +} from '@/lib/server/source-control-oauth-redirect'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -15,9 +22,32 @@ export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { const webEnv = await bootstrapWebRuntimeEnv(); const publicAppUrl = webEnv.R_PUBLIC_URL ?? webEnv.R_APP_URL; - const redirect = new URL('/setup', publicAppUrl); - redirect.searchParams.set('step', 'source-control-connect'); + const { setupOpen } = await getSetupBootstrapState(); + const returnTarget = resolveSourceControlOAuthReturnTarget({ + requestedTarget: request.cookies.get( + getSourceControlOAuthReturnCookieName('bitbucket'), + )?.value, + setupOpen, + }); + const redirect = new URL(returnTarget, publicAppUrl); const response = () => NextResponse.redirect(redirect); + const clearCookies = (result: NextResponse) => { + result.cookies.set('roomote-bitbucket-oauth-state', '', { + httpOnly: true, + sameSite: 'lax', + secure: publicAppUrl.startsWith('https://'), + path: BITBUCKET_OAUTH_CALLBACK_PATH, + maxAge: 0, + }); + result.cookies.set(getSourceControlOAuthReturnCookieName('bitbucket'), '', { + httpOnly: true, + sameSite: 'lax', + secure: publicAppUrl.startsWith('https://'), + path: BITBUCKET_OAUTH_CALLBACK_PATH, + maxAge: 0, + }); + return result; + }; const authResult = await authorize(); const state = request.nextUrl.searchParams.get('state'); const expectedState = request.cookies.get( @@ -32,7 +62,7 @@ export async function GET(request: NextRequest) { !code ) { redirect.searchParams.set('bitbucket', 'error'); - return response(); + return clearCookies(response()); } try { const [clientId, clientSecret] = await Promise.all([ @@ -49,20 +79,28 @@ export async function GET(request: NextRequest) { code, redirectUri: buildBitbucketOAuthRedirectUri(publicAppUrl), }); - await syncRepositoriesCommand(authResult, { provider: 'bitbucket' }); - redirect.searchParams.set('bitbucket', 'connected'); - redirect.searchParams.set('sync', '1'); + if (!isSetupOAuthReturnTarget(returnTarget)) { + const syncResult = await syncRepositoriesCommand(authResult, { + provider: 'bitbucket', + }); + if (!syncResult.success) { + throw new Error(syncResult.error); + } + } + const resultTarget = addSourceControlOAuthResult( + returnTarget, + 'bitbucket', + 'connected', + ); + redirect.href = new URL(resultTarget, publicAppUrl).href; } catch (error) { console.error('[Bitbucket OAuth] callback failed', error); - redirect.searchParams.set('bitbucket', 'error'); + const resultTarget = addSourceControlOAuthResult( + returnTarget, + 'bitbucket', + 'error', + ); + redirect.href = new URL(resultTarget, publicAppUrl).href; } - const result = response(); - result.cookies.set('roomote-bitbucket-oauth-state', '', { - httpOnly: true, - sameSite: 'lax', - secure: publicAppUrl.startsWith('https://'), - path: BITBUCKET_OAUTH_CALLBACK_PATH, - maxAge: 0, - }); - return result; + return clearCookies(response()); } diff --git a/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts b/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts index 19ff2012f..8a9d59275 100644 --- a/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts +++ b/apps/web/src/app/api/source-control/gitea/oauth/authorize/route.ts @@ -8,6 +8,11 @@ import { } from '@roomote/gitea'; import { authorize, getCallbackHost } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; +import { + getSourceControlOAuthReturnCookieName, + normalizeSourceControlOAuthReturnTarget, + SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE, +} from '@/lib/server/source-control-oauth-redirect'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -36,12 +41,26 @@ export async function GET(request: NextRequest) { redirectUri, }); const response = NextResponse.redirect(url); + const returnTarget = normalizeSourceControlOAuthReturnTarget( + request.nextUrl.searchParams.get('redirectTo'), + ); response.cookies.set('roomote-gitea-oauth-state', state, { httpOnly: true, sameSite: 'lax', secure: webEnv.R_APP_URL.startsWith('https://'), path: '/api/source-control/gitea/oauth', - maxAge: 600, + maxAge: SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE, }); + response.cookies.set( + getSourceControlOAuthReturnCookieName('gitea'), + returnTarget ?? '', + { + httpOnly: true, + sameSite: 'lax', + secure: webEnv.R_APP_URL.startsWith('https://'), + path: '/api/source-control/gitea/oauth', + maxAge: SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE, + }, + ); return response; } diff --git a/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts b/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts index 13bfbd0d7..f2f31ec1a 100644 --- a/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts +++ b/apps/web/src/app/api/source-control/gitea/oauth/callback/route.ts @@ -8,6 +8,14 @@ import { } from '@roomote/gitea'; import { authorize, getCallbackHost } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; +import { getSetupBootstrapState } from '@/lib/server/setup-bootstrap-state'; +import { syncRepositoriesCommand } from '@/trpc/commands/source-control'; +import { + addSourceControlOAuthResult, + getSourceControlOAuthReturnCookieName, + isSetupOAuthReturnTarget, + resolveSourceControlOAuthReturnTarget, +} from '@/lib/server/source-control-oauth-redirect'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -15,9 +23,32 @@ export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { const webEnv = await bootstrapWebRuntimeEnv(); const callbackOrigin = new URL(getCallbackHost(request)).origin; - const redirect = new URL('/setup', callbackOrigin); - redirect.searchParams.set('step', 'source-control-connect'); + const { setupOpen } = await getSetupBootstrapState(); + const returnTarget = resolveSourceControlOAuthReturnTarget({ + requestedTarget: request.cookies.get( + getSourceControlOAuthReturnCookieName('gitea'), + )?.value, + setupOpen, + }); + const redirect = new URL(returnTarget, callbackOrigin); const response = () => NextResponse.redirect(redirect); + const clearCookies = (result: NextResponse) => { + result.cookies.set('roomote-gitea-oauth-state', '', { + httpOnly: true, + sameSite: 'lax', + secure: webEnv.R_APP_URL.startsWith('https://'), + path: '/api/source-control/gitea/oauth', + maxAge: 0, + }); + result.cookies.set(getSourceControlOAuthReturnCookieName('gitea'), '', { + httpOnly: true, + sameSite: 'lax', + secure: webEnv.R_APP_URL.startsWith('https://'), + path: '/api/source-control/gitea/oauth', + maxAge: 0, + }); + return result; + }; const authResult = await authorize(); const state = request.nextUrl.searchParams.get('state'); const expectedState = request.cookies.get('roomote-gitea-oauth-state')?.value; @@ -30,7 +61,7 @@ export async function GET(request: NextRequest) { !code ) { redirect.searchParams.set('gitea', 'error'); - return response(); + return clearCookies(response()); } try { const [baseUrl, clientId, clientSecret] = await Promise.all([ @@ -48,19 +79,28 @@ export async function GET(request: NextRequest) { code, redirectUri: buildGiteaOAuthRedirectUri(callbackOrigin), }); - redirect.searchParams.set('gitea', 'connected'); - redirect.searchParams.set('sync', '1'); + if (!isSetupOAuthReturnTarget(returnTarget)) { + const syncResult = await syncRepositoriesCommand(authResult, { + provider: 'gitea', + }); + if (!syncResult.success) { + throw new Error(syncResult.error); + } + } + const resultTarget = addSourceControlOAuthResult( + returnTarget, + 'gitea', + 'connected', + ); + redirect.href = new URL(resultTarget, callbackOrigin).href; } catch (error) { console.error('[Gitea OAuth] callback failed', error); - redirect.searchParams.set('gitea', 'error'); + const resultTarget = addSourceControlOAuthResult( + returnTarget, + 'gitea', + 'error', + ); + redirect.href = new URL(resultTarget, callbackOrigin).href; } - const result = response(); - result.cookies.set('roomote-gitea-oauth-state', '', { - httpOnly: true, - sameSite: 'lax', - secure: webEnv.R_APP_URL.startsWith('https://'), - path: '/api/source-control/gitea/oauth', - maxAge: 0, - }); - return result; + return clearCookies(response()); } diff --git a/apps/web/src/app/api/source-control/gitlab/oauth/authorize/route.ts b/apps/web/src/app/api/source-control/gitlab/oauth/authorize/route.ts index fa5e5d2d6..1c2838b08 100644 --- a/apps/web/src/app/api/source-control/gitlab/oauth/authorize/route.ts +++ b/apps/web/src/app/api/source-control/gitlab/oauth/authorize/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { resolveDeploymentEnvVar } from '@roomote/db/server'; import { @@ -8,11 +8,16 @@ import { } from '@roomote/gitlab'; import { authorize } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; +import { + getSourceControlOAuthReturnCookieName, + normalizeSourceControlOAuthReturnTarget, + SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE, +} from '@/lib/server/source-control-oauth-redirect'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function GET() { +export async function GET(request?: NextRequest) { const authResult = await authorize(); const webEnv = await bootstrapWebRuntimeEnv(); if (!authResult.success || !authResult.isAdmin) { @@ -36,12 +41,26 @@ export async function GET() { redirectUri, }); const response = NextResponse.redirect(url); + const returnTarget = normalizeSourceControlOAuthReturnTarget( + request?.nextUrl.searchParams.get('redirectTo'), + ); response.cookies.set('roomote-gitlab-oauth-state', state, { httpOnly: true, sameSite: 'lax', secure: publicAppUrl.startsWith('https://'), path: '/api/source-control/gitlab/oauth', - maxAge: 600, + maxAge: SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE, }); + response.cookies.set( + getSourceControlOAuthReturnCookieName('gitlab'), + returnTarget ?? '', + { + httpOnly: true, + sameSite: 'lax', + secure: publicAppUrl.startsWith('https://'), + path: '/api/source-control/gitlab/oauth', + maxAge: SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE, + }, + ); return response; } diff --git a/apps/web/src/app/api/source-control/gitlab/oauth/callback/__tests__/route.test.ts b/apps/web/src/app/api/source-control/gitlab/oauth/callback/__tests__/route.test.ts index a33705e43..50e424901 100644 --- a/apps/web/src/app/api/source-control/gitlab/oauth/callback/__tests__/route.test.ts +++ b/apps/web/src/app/api/source-control/gitlab/oauth/callback/__tests__/route.test.ts @@ -4,14 +4,18 @@ const { authorizeMock, bootstrapWebRuntimeEnvMock, exchangeGitLabOAuthCodeMock, + getSetupBootstrapStateMock, resolveDeploymentEnvVarMock, resolveGitLabBaseUrlMock, + syncRepositoriesMock, } = vi.hoisted(() => ({ authorizeMock: vi.fn(), bootstrapWebRuntimeEnvMock: vi.fn(), exchangeGitLabOAuthCodeMock: vi.fn(), + getSetupBootstrapStateMock: vi.fn(), resolveDeploymentEnvVarMock: vi.fn(), resolveGitLabBaseUrlMock: vi.fn(), + syncRepositoriesMock: vi.fn(), })); vi.mock('@/lib/server', () => ({ @@ -22,6 +26,14 @@ vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ bootstrapWebRuntimeEnv: bootstrapWebRuntimeEnvMock, })); +vi.mock('@/lib/server/setup-bootstrap-state', () => ({ + getSetupBootstrapState: getSetupBootstrapStateMock, +})); + +vi.mock('@/trpc/commands/source-control', () => ({ + syncRepositoriesCommand: syncRepositoriesMock, +})); + vi.mock('@roomote/db/server', () => ({ resolveDeploymentEnvVar: resolveDeploymentEnvVarMock, })); @@ -77,6 +89,8 @@ describe('GET /api/source-control/gitlab/oauth/callback', () => { return null; }); exchangeGitLabOAuthCodeMock.mockResolvedValue(undefined); + getSetupBootstrapStateMock.mockResolvedValue({ setupOpen: true }); + syncRepositoriesMock.mockResolvedValue({ success: true, repositories: [] }); }); it('exchanges the code with redirect_uri built from R_PUBLIC_URL', async () => { @@ -147,4 +161,42 @@ describe('GET /api/source-control/gitlab/oauth/callback', () => { ); expect(exchangeGitLabOAuthCodeMock).not.toHaveBeenCalled(); }); + + it('returns to settings and syncs when setup is already complete', async () => { + getSetupBootstrapStateMock.mockResolvedValue({ setupOpen: false }); + + const response = await GET( + buildRequest( + '?code=auth-code&state=state-1', + 'roomote-gitlab-oauth-state=state-1; roomote-gitlab-oauth-return-to=%2Fsetup%3Fstep%3Dsource-control-connect', + ), + ); + + expect(response.headers.get('location')).toBe( + 'https://customer.roomote.ai/settings/source-control?gitlab=connected', + ); + expect(syncRepositoriesMock).toHaveBeenCalledWith( + { success: true, isAdmin: true }, + { provider: 'gitlab' }, + ); + }); + + it('reports a repository sync failure instead of a connected result', async () => { + getSetupBootstrapStateMock.mockResolvedValue({ setupOpen: false }); + syncRepositoriesMock.mockResolvedValue({ + success: false, + error: 'GitLab rejected the deployment credential.', + }); + + const response = await GET( + buildRequest( + '?code=auth-code&state=state-1', + 'roomote-gitlab-oauth-state=state-1; roomote-gitlab-oauth-return-to=%2Fsettings%2Fsource-control', + ), + ); + + expect(response.headers.get('location')).toBe( + 'https://customer.roomote.ai/settings/source-control?gitlab=error', + ); + }); }); diff --git a/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts b/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts index dd63facbd..a46831599 100644 --- a/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts +++ b/apps/web/src/app/api/source-control/gitlab/oauth/callback/route.ts @@ -8,6 +8,14 @@ import { } from '@roomote/gitlab'; import { authorize } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; +import { getSetupBootstrapState } from '@/lib/server/setup-bootstrap-state'; +import { syncRepositoriesCommand } from '@/trpc/commands/source-control'; +import { + addSourceControlOAuthResult, + getSourceControlOAuthReturnCookieName, + isSetupOAuthReturnTarget, + resolveSourceControlOAuthReturnTarget, +} from '@/lib/server/source-control-oauth-redirect'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -15,9 +23,32 @@ export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { const webEnv = await bootstrapWebRuntimeEnv(); const publicAppUrl = webEnv.R_PUBLIC_URL ?? webEnv.R_APP_URL; - const redirect = new URL('/setup', publicAppUrl); - redirect.searchParams.set('step', 'source-control-connect'); + const { setupOpen } = await getSetupBootstrapState(); + const returnTarget = resolveSourceControlOAuthReturnTarget({ + requestedTarget: request.cookies.get( + getSourceControlOAuthReturnCookieName('gitlab'), + )?.value, + setupOpen, + }); + const redirect = new URL(returnTarget, publicAppUrl); const response = () => NextResponse.redirect(redirect); + const clearCookies = (result: NextResponse) => { + result.cookies.set('roomote-gitlab-oauth-state', '', { + httpOnly: true, + sameSite: 'lax', + secure: publicAppUrl.startsWith('https://'), + path: '/api/source-control/gitlab/oauth', + maxAge: 0, + }); + result.cookies.set(getSourceControlOAuthReturnCookieName('gitlab'), '', { + httpOnly: true, + sameSite: 'lax', + secure: publicAppUrl.startsWith('https://'), + path: '/api/source-control/gitlab/oauth', + maxAge: 0, + }); + return result; + }; const authResult = await authorize(); const state = request.nextUrl.searchParams.get('state'); const expectedState = request.cookies.get( @@ -32,7 +63,7 @@ export async function GET(request: NextRequest) { !code ) { redirect.searchParams.set('gitlab', 'error'); - return response(); + return clearCookies(response()); } try { const [baseUrl, clientId, clientSecret] = await Promise.all([ @@ -49,19 +80,28 @@ export async function GET(request: NextRequest) { code, redirectUri: buildGitLabOAuthRedirectUri(publicAppUrl), }); - redirect.searchParams.set('gitlab', 'connected'); - redirect.searchParams.set('sync', '1'); + if (!isSetupOAuthReturnTarget(returnTarget)) { + const syncResult = await syncRepositoriesCommand(authResult, { + provider: 'gitlab', + }); + if (!syncResult.success) { + throw new Error(syncResult.error); + } + } + const resultTarget = addSourceControlOAuthResult( + returnTarget, + 'gitlab', + 'connected', + ); + redirect.href = new URL(resultTarget, publicAppUrl).href; } catch (error) { console.error('[GitLab OAuth] callback failed', error); - redirect.searchParams.set('gitlab', 'error'); + const resultTarget = addSourceControlOAuthResult( + returnTarget, + 'gitlab', + 'error', + ); + redirect.href = new URL(resultTarget, publicAppUrl).href; } - const result = response(); - result.cookies.set('roomote-gitlab-oauth-state', '', { - httpOnly: true, - sameSite: 'lax', - secure: publicAppUrl.startsWith('https://'), - path: '/api/source-control/gitlab/oauth', - maxAge: 0, - }); - return result; + return clearCookies(response()); } diff --git a/apps/web/src/components/settings/SourceControl.test.tsx b/apps/web/src/components/settings/SourceControl.test.tsx index 7af2af35c..17413f8f4 100644 --- a/apps/web/src/components/settings/SourceControl.test.tsx +++ b/apps/web/src/components/settings/SourceControl.test.tsx @@ -330,8 +330,17 @@ vi.mock('@/components/settings', () => ({ })); vi.mock('./SourceControlConfigForm', () => ({ - SourceControlConfigForm: ({ provider }: { provider: string }) => ( -
+ SourceControlConfigForm: ({ + provider, + showSetupInstructions, + }: { + provider: string; + showSetupInstructions?: boolean; + }) => ( +
), })); @@ -400,6 +409,11 @@ describe('SourceControl settings', () => { expect(getProviderConfigOAuthAuthorizePath('bitbucket')).toBe( '/api/source-control/bitbucket/oauth/authorize', ); + expect( + getProviderConfigOAuthAuthorizePath('gitea', '/settings/source-control'), + ).toBe( + '/api/source-control/gitea/oauth/authorize?redirectTo=%2Fsettings%2Fsource-control', + ); expect(getProviderConfigOAuthAuthorizePath('ado')).toBeNull(); }); @@ -522,6 +536,16 @@ describe('SourceControl settings', () => { ); }); + it('shows retry guidance when an OAuth callback reports a failed connection or sync', () => { + state.searchParams = 'gitea=error'; + + render(); + + expect(toast.error).toHaveBeenCalledWith( + 'Failed to connect or sync Gitea. Check the credentials and try again.', + ); + }); + it('requests the regular callback background when updating GitHub from settings', () => { state.searchParams = 'tab=source-control'; @@ -631,15 +655,10 @@ describe('SourceControl settings', () => { fireEvent.click(screen.getByRole('button', { name: 'Set it up' })); - expect( - screen.getByRole('link', { name: /GitLab OAuth application/ }), - ).toHaveAttribute( - 'href', - 'https://gitlab.com/-/user_settings/applications', + expect(screen.getByTestId('source-control-config-gitlab')).toHaveAttribute( + 'data-show-setup-instructions', + 'true', ); - expect( - screen.getByTestId('source-control-config-gitlab'), - ).toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Hide config' }), ).not.toBeInTheDocument(); @@ -666,10 +685,10 @@ describe('SourceControl settings', () => { fireEvent.click(screen.getByRole('button', { name: 'Set it up' })); - expect( - screen.getByRole('link', { name: /Azure DevOps connection/ }), - ).toHaveAttribute('href', 'https://dev.azure.com/_usersSettings/tokens'); - expect(screen.getByTestId('source-control-config-ado')).toBeInTheDocument(); + expect(screen.getByTestId('source-control-config-ado')).toHaveAttribute( + 'data-show-setup-instructions', + 'true', + ); }); it('keeps every expanded provider form visible when setting up multiple providers', () => { diff --git a/apps/web/src/components/settings/SourceControl.tsx b/apps/web/src/components/settings/SourceControl.tsx index f0f8ef494..938403ca4 100644 --- a/apps/web/src/components/settings/SourceControl.tsx +++ b/apps/web/src/components/settings/SourceControl.tsx @@ -24,8 +24,6 @@ import { useSyncRepositories, } from '@/hooks/source-control'; import { useAuthorizedUser } from '@/hooks/useUser'; -import { getSourceControlSetupCopy } from '@/app/(onboarding)/setup/sourceControlSetupCopy'; - import { useCreateGitHubAppManifest, useEnableGitHubApp, @@ -40,7 +38,6 @@ import { Button, ChevronDown, ChevronUp, - ExternalLink, GitMerge, Input, Label, @@ -145,24 +142,37 @@ type TokenProviderState = { export function getProviderConfigOAuthAuthorizePath( provider: SourceControlTokenBackedProvider, + redirectTo?: string, ): string | null { - return provider === 'gitlab' || - provider === 'gitea' || - provider === 'bitbucket' - ? `/api/source-control/${provider}/oauth/authorize` - : null; + if ( + provider !== 'gitlab' && + provider !== 'gitea' && + provider !== 'bitbucket' + ) { + return null; + } + + const path = `/api/source-control/${provider}/oauth/authorize`; + return redirectTo + ? `${path}?redirectTo=${encodeURIComponent(redirectTo)}` + : path; } export function completeProviderConfigSave({ provider, navigate, sync, + redirectTo, }: { provider: SourceControlTokenBackedProvider; navigate: (path: string) => void; sync: () => void; + redirectTo?: string; }): void { - const authorizePath = getProviderConfigOAuthAuthorizePath(provider); + const authorizePath = getProviderConfigOAuthAuthorizePath( + provider, + redirectTo, + ); if (authorizePath) { navigate(authorizePath); @@ -247,6 +257,29 @@ export function SourceControl() { const adoIsConnected = (adoRepositories.data?.length ?? 0) > 0; const search = searchParams.toString(); const redirectTarget = search ? `${pathname}?${search}` : pathname; + const failedProvider = sourceControlTokenBackedProviders.find( + (provider) => searchParams.get(provider) === 'error', + ); + + useEffect(() => { + if (!failedProvider) { + return; + } + + const label = sourceControlProviderDescriptors[failedProvider].label; + toast.error( + `Failed to connect or sync ${label}. Check the credentials and try again.`, + ); + + const nextSearchParams = new URLSearchParams(search); + nextSearchParams.delete(failedProvider); + const nextSearch = nextSearchParams.toString(); + window.history.replaceState( + window.history.state, + '', + nextSearch ? `${pathname}?${nextSearch}` : pathname, + ); + }, [failedProvider, pathname, search]); const tokenProviderState = { gitlab: { @@ -399,12 +432,14 @@ export function SourceControl() { completeProviderConfigSave({ provider, navigate: (path) => window.location.assign(path), sync: tokenProviderState[provider].sync.mutate, + redirectTo: redirectTarget, }) } /> @@ -737,9 +772,7 @@ function SourceControlProviderBlock({ {!isConfigured ? ( isGitHubUnconfigured ? ( - ) : ( - - ) + ) : null ) : null} {shouldShowGitHubConfigForm ? configForm : null} {isPending ? null : repositoryCount > 0 && repositories ? ( @@ -924,54 +957,6 @@ function GitHubAppSettingsSetupPanel({ setup }: { setup: ReactNode }) { ); } -function ProviderSetupInstructions({ - provider, - title, -}: { - provider: SourceControlProvider; - title: string; -}) { - const setupCopy = getSourceControlSetupCopy(provider); - - return ( -
-
-

- Create {setupCopy.setupLabelArticle ?? 'a'}{' '} - {setupCopy.creationHref ? ( - - {setupCopy.setupLabel} - - - ) : ( - setupCopy.setupLabel - )}{' '} - for {title}. -

-

- {setupCopy.creationHint ?? - 'Roomote will guide you through app creation and installation.'} -

-
-
-

- Copy credentials and finish setup. -

-

- Copy the generated credential, then paste it below. Roomote stores - deployment credentials securely and uses them to sync repositories and - configure pull request webhooks. -

-
-
- ); -} - function isProviderConfigured( configStatus: ReturnType['data'], provider: SourceControlProvider, diff --git a/apps/web/src/components/settings/SourceControlConfigForm.client.test.tsx b/apps/web/src/components/settings/SourceControlConfigForm.client.test.tsx index c17e030f3..95d345208 100644 --- a/apps/web/src/components/settings/SourceControlConfigForm.client.test.tsx +++ b/apps/web/src/components/settings/SourceControlConfigForm.client.test.tsx @@ -152,6 +152,44 @@ function buildConfigStatus( }; } +function buildUnconfiguredProviderStatus( + provider: Exclude, +): SetupSourceControlStatus { + const catalogProvider = SETUP_SOURCE_CONTROL_PROVIDER_CATALOG.find( + (candidate) => candidate.provider === provider, + )!; + + return { + selectedProvider: provider, + preselectedProvider: provider, + runtimeConfiguredProvider: null, + runtimeConfiguredProviders: [], + lockReason: null, + connectedProvider: null, + setupSatisfied: false, + setupSatisfiedByRuntimeEnv: false, + providers: [ + { + ...catalogProvider, + runtimeConfigSatisfied: false, + savedConfigSatisfied: false, + configSatisfied: false, + configStepSatisfied: false, + configSatisfiedByRuntimeEnv: false, + connected: false, + repositoryCount: 0, + fields: catalogProvider.fields.map((field) => ({ + ...field, + runtimeSatisfied: false, + savedSatisfied: false, + savedValue: null, + satisfiedByEnvVarName: null, + })), + }, + ], + }; +} + describe('SourceControlConfigForm', () => { beforeEach(() => { saveMutateMock.mockReset(); @@ -386,6 +424,31 @@ describe('SourceControlConfigForm', () => { expect(screen.getByText(/Azure DevOps Webhook Secret/)).toBeInTheDocument(); }); + it.each([ + ['gitlab', '/api/source-control/gitlab/oauth/callback'], + ['gitea', '/api/source-control/gitea/oauth/callback'], + ['bitbucket', '/api/auth/oauth2/callback/bitbucket'], + ['ado', '/api/auth/oauth2/callback/ado'], + ] as const)( + 'shows numbered setup instructions and the callback URL for %s', + (provider, callbackPath) => { + render( + , + ); + + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect( + screen.getByText(`http://localhost:3000${callbackPath}`), + ).toBeInTheDocument(); + }, + ); + function buildAdoDelegatedStatus(linkedAccountField: { savedSatisfied: boolean; savedValue: string | null; @@ -461,6 +524,36 @@ describe('SourceControlConfigForm', () => { expect(screen.getByText(/Not in use yet/)).toBeInTheDocument(); }); + it('allows saving delegated Azure DevOps app settings before account linking', () => { + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('ADO_ORGANIZATION'), { + target: { value: 'acme' }, + }); + fireEvent.change(screen.getByPlaceholderText('ADO_CLIENT_ID'), { + target: { value: 'client-id' }, + }); + fireEvent.change(screen.getByPlaceholderText('ADO_CLIENT_SECRET'), { + target: { value: 'client-secret' }, + }); + fireEvent.change(screen.getByPlaceholderText('ADO_TENANT_ID'), { + target: { value: 'tenant-id' }, + }); + + expect( + screen.getByRole('button', { name: 'Save configuration' }), + ).toBeEnabled(); + }); + it('says a reconnected Azure DevOps account is not in use while the saved id still belongs to the previous account', () => { adoLinkedAccountRef.current = { data: { diff --git a/apps/web/src/components/settings/SourceControlConfigForm.tsx b/apps/web/src/components/settings/SourceControlConfigForm.tsx index 6d4b70a9a..da2ac93a3 100644 --- a/apps/web/src/components/settings/SourceControlConfigForm.tsx +++ b/apps/web/src/components/settings/SourceControlConfigForm.tsx @@ -21,11 +21,25 @@ import { Input, Spinner, Trash2, + ExternalLink, } from '@/components/system'; import { useAdoLinkedAccount, useAuthenticateAdoAccount, } from '@/hooks/linked-accounts'; +import { + AdoSourceControlConfig, + AdoSourceControlInstructions, + DEFAULT_ADO_AUTH_MODE, +} from '@/app/(onboarding)/setup/AdoSourceControlConfig'; +import { + BitbucketSourceControlCreation, + BitbucketSourceControlInstructions, +} from '@/app/(onboarding)/setup/BitbucketSourceControlConfig'; +import { GiteaSourceControlInstructions } from '@/app/(onboarding)/setup/GiteaSourceControlConfig'; +import { GitLabSourceControlInstructions } from '@/app/(onboarding)/setup/GitLabSourceControlConfig'; +import { NumberedStep } from '@/app/(onboarding)/setup/NumberedStep'; +import { getSourceControlSetupCopy } from '@/app/(onboarding)/setup/sourceControlSetupCopy'; const MASKED_VALUE = '••••••••••••••••••••••••••••'; @@ -76,11 +90,13 @@ export function SourceControlConfigForm({ configStatus, onSaved, saveSuccessMessage, + showSetupInstructions = false, }: { provider: SetupSourceControlStatus['preselectedProvider']; configStatus: SetupSourceControlStatus | undefined; onSaved?: () => void; saveSuccessMessage?: string; + showSetupInstructions?: boolean; }) { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -116,7 +132,7 @@ export function SourceControlConfigForm({ >({}); const [removeDialogOpen, setRemoveDialogOpen] = useState(false); const [adoAuthMode, setAdoAuthMode] = useState<'pat' | 'entra' | 'delegated'>( - 'pat', + showSetupInstructions ? DEFAULT_ADO_AUTH_MODE : 'pat', ); const adoLinkedAccount = useAdoLinkedAccount(); const authenticateAdoAccount = useAuthenticateAdoAccount(); @@ -208,13 +224,23 @@ export function SourceControlConfigForm({ ? 'delegated' : hasEntra && !hasPat ? 'entra' - : 'pat', + : hasPat + ? 'pat' + : showSetupInstructions + ? DEFAULT_ADO_AUTH_MODE + : 'pat', ); } // providerStatus.fields array identity is intentionally omitted; content // key and derived non-secret values drive resets instead of query refetches. // eslint-disable-next-line react-hooks/exhaustive-deps -- content-keyed - }, [provider, nonSecretInitialValues, isAdo, nonSecretInitialValuesKey]); + }, [ + provider, + nonSecretInitialValues, + isAdo, + nonSecretInitialValuesKey, + showSetupInstructions, + ]); if (!providerStatus) { return null; @@ -270,11 +296,7 @@ export function SourceControlConfigForm({ nextValue.length === 0 ); }) || - (isAdo && - (adoAuthMode === 'pat' - ? !hasAdoPat - : !hasAdoAppCredentials || - (adoAuthMode === 'delegated' && !adoLinkedAccount.data?.account))); + (isAdo && (adoAuthMode === 'pat' ? !hasAdoPat : !hasAdoAppCredentials)); const hasNewValues = visibleFields.some((field) => { if (field.runtimeSatisfied) { @@ -289,51 +311,73 @@ export function SourceControlConfigForm({ const hasSavedValues = provider === 'github' && providerStatus.fields.some((field) => field.savedSatisfied); + const publicOrigin = + typeof window === 'undefined' + ? 'https://your-deployment-url' + : window.location.origin; + const setupCopy = getSourceControlSetupCopy(provider); - return ( -
- {isAdo ? ( -
- - - + const adoModeSelector = showSetupInstructions ? ( + + ) : ( +
+ + + +
+ ); + + const credentials = ( + <> + {showSetupInstructions ? ( +
+

+ Enter the values below for your {providerStatus.label} integration. +

+

+ Roomote encrypts saved deployment credentials and uses them to sync + repositories and configure pull request webhooks. +

) : null} - {isAdo && adoAuthMode !== 'pat' ? ( + {isAdo && adoAuthMode !== 'pat' && !showSetupInstructions ? (

The Microsoft Entra app registration needs the{' '} @@ -507,6 +551,71 @@ export function SourceControlConfigForm({ {hasNewValues ? 'Save configuration' : 'Save'}

+ + ); + + return ( +
+ {showSetupInstructions && !isAdo ? ( + + {provider === 'bitbucket' ? ( + + ) : ( + <> +

+ Create {setupCopy.setupLabelArticle ?? 'a'}{' '} + {setupCopy.creationHref ? ( + + {setupCopy.setupLabel} + + + ) : ( + setupCopy.setupLabel + )} + . +

+ {setupCopy.creationHint ? ( +

+ {setupCopy.creationHint} +

+ ) : null} + + )} +
+ ) : null} + {isAdo ? ( + showSetupInstructions ? ( + {adoModeSelector} + ) : ( + adoModeSelector + ) + ) : null} + {showSetupInstructions ? ( + + {provider === 'gitlab' ? ( + + ) : provider === 'gitea' ? ( + + ) : provider === 'bitbucket' ? ( + + ) : isAdo ? ( + + ) : null} + + ) : null} + {showSetupInstructions ? ( + {credentials} + ) : ( + credentials + )} diff --git a/apps/web/src/lib/server/setup-bootstrap-state.ts b/apps/web/src/lib/server/setup-bootstrap-state.ts new file mode 100644 index 000000000..19fecf530 --- /dev/null +++ b/apps/web/src/lib/server/setup-bootstrap-state.ts @@ -0,0 +1,17 @@ +import { db, deploymentSettings, eq } from '@roomote/db/server'; + +export async function getSetupBootstrapState(): Promise<{ + setupOpen: boolean; +}> { + const [deployment] = await db + .select({ + setupCompletedAt: deploymentSettings.setupCompletedAt, + }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .limit(1); + + return { + setupOpen: deployment?.setupCompletedAt == null, + }; +} diff --git a/apps/web/src/lib/server/source-control-oauth-redirect.test.ts b/apps/web/src/lib/server/source-control-oauth-redirect.test.ts new file mode 100644 index 000000000..d61842488 --- /dev/null +++ b/apps/web/src/lib/server/source-control-oauth-redirect.test.ts @@ -0,0 +1,55 @@ +import { + addSourceControlOAuthResult, + normalizeSourceControlOAuthReturnTarget, + resolveSourceControlOAuthReturnTarget, + SOURCE_CONTROL_SETTINGS_PATH, + SOURCE_CONTROL_SETUP_PATH, +} from './source-control-oauth-redirect'; + +describe('source-control OAuth redirect handling', () => { + it('defaults incomplete setup flows to the source-control step', () => { + expect( + resolveSourceControlOAuthReturnTarget({ + setupOpen: true, + }), + ).toBe(SOURCE_CONTROL_SETUP_PATH); + }); + + it('never returns a completed deployment to setup', () => { + expect( + resolveSourceControlOAuthReturnTarget({ + setupOpen: false, + requestedTarget: '/setup?step=source-control-connect', + }), + ).toBe(SOURCE_CONTROL_SETTINGS_PATH); + }); + + it('rejects external OAuth return targets', () => { + expect( + normalizeSourceControlOAuthReturnTarget('https://evil.example'), + ).toBe(null); + expect(normalizeSourceControlOAuthReturnTarget('//evil.example')).toBe( + null, + ); + }); + + it('preserves settings queries without adding a setup sync marker', () => { + expect( + addSourceControlOAuthResult( + '/settings/source-control?tab=source-control', + 'gitea', + 'connected', + ), + ).toBe('/settings/source-control?tab=source-control&gitea=connected'); + }); + + it('adds the sync marker only for setup returns', () => { + expect( + addSourceControlOAuthResult( + SOURCE_CONTROL_SETUP_PATH, + 'gitlab', + 'connected', + ), + ).toBe('/setup?step=source-control-connect&gitlab=connected&sync=1'); + }); +}); diff --git a/apps/web/src/lib/server/source-control-oauth-redirect.ts b/apps/web/src/lib/server/source-control-oauth-redirect.ts new file mode 100644 index 000000000..b85b4ffee --- /dev/null +++ b/apps/web/src/lib/server/source-control-oauth-redirect.ts @@ -0,0 +1,70 @@ +export const SOURCE_CONTROL_SETTINGS_PATH = '/settings/source-control'; +export const SOURCE_CONTROL_SETUP_PATH = '/setup?step=source-control-connect'; +export const SOURCE_CONTROL_OAUTH_COOKIE_MAX_AGE = 600; + +type SourceControlOAuthProvider = 'gitlab' | 'gitea' | 'bitbucket'; + +export function getSourceControlOAuthReturnCookieName( + provider: SourceControlOAuthProvider, +): string { + return `roomote-${provider}-oauth-return-to`; +} + +export function normalizeSourceControlOAuthReturnTarget( + value: string | null | undefined, +): string | null { + const target = value?.trim(); + if ( + !target || + !target.startsWith('/') || + target.startsWith('//') || + target.includes('://') + ) { + return null; + } + + return target; +} + +function isSetupPath(path: string): boolean { + return path === '/setup' || path.startsWith('/setup?'); +} + +export function resolveSourceControlOAuthReturnTarget({ + requestedTarget, + setupOpen, +}: { + requestedTarget?: string | null; + setupOpen: boolean; +}): string { + const fallback = setupOpen + ? SOURCE_CONTROL_SETUP_PATH + : SOURCE_CONTROL_SETTINGS_PATH; + const target = + normalizeSourceControlOAuthReturnTarget(requestedTarget) ?? fallback; + + // A setup-originated OAuth flow can outlive setup completion in another tab + // or while the provider is open. Never re-enter the completed setup wizard. + return !setupOpen && isSetupPath(target) + ? SOURCE_CONTROL_SETTINGS_PATH + : target; +} + +export function isSetupOAuthReturnTarget(path: string): boolean { + return isSetupPath(path); +} + +export function addSourceControlOAuthResult( + target: string, + provider: SourceControlOAuthProvider, + result: 'connected' | 'error', +): string { + const url = new URL(target, 'https://roomote.invalid'); + url.searchParams.set(provider, result); + + if (isSetupOAuthReturnTarget(target)) { + url.searchParams.set('sync', '1'); + } + + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/apps/web/src/trpc/commands/setup/shared.ts b/apps/web/src/trpc/commands/setup/shared.ts index 69ab30c14..4f9d090a4 100644 --- a/apps/web/src/trpc/commands/setup/shared.ts +++ b/apps/web/src/trpc/commands/setup/shared.ts @@ -16,6 +16,7 @@ import { } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; +export { getSetupBootstrapState } from '@/lib/server/setup-bootstrap-state'; type SetupBaseStatus = { hasGitHub: boolean; @@ -26,22 +27,6 @@ type SetupBaseStatus = { setupNewState: ReturnType; }; -export async function getSetupBootstrapState(): Promise<{ - setupOpen: boolean; -}> { - const [deployment] = await db - .select({ - setupCompletedAt: deploymentSettings.setupCompletedAt, - }) - .from(deploymentSettings) - .where(eq(deploymentSettings.id, 'default')) - .limit(1); - - return { - setupOpen: deployment?.setupCompletedAt == null, - }; -} - export function assertAdmin(auth: UserAuthSuccess) { if (!auth.isAdmin) { throw new Error('Unauthorized'); diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index 86af20806..27ed31dc1 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -968,7 +968,10 @@ export async function saveSourceControlConfigCommand( ) { assertAdmin(auth); - await assertValidSourceControlConfigInput(input); + await assertValidSourceControlConfigInput({ + ...input, + allowIncompleteDelegated: true, + }); return db.transaction(async (tx) => { const providerStatus = await saveSourceControlConfigValues({