-
+
{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 ? (
-
-
setAdoAuthMode('pat')}
- >
- Personal access token
-
- Use a PAT from a bot or service account.
-
-
-
setAdoAuthMode('entra')}
- >
-
- Microsoft Entra service principal
-
-
- Use short-lived service-principal tokens.
-
-
-
setAdoAuthMode('delegated')}
- >
-
- Connect with your Microsoft account
-
-
- Use a delegated Azure DevOps account.
-
-
+ const adoModeSelector = showSetupInstructions ? (
+
+ ) : (
+
+ setAdoAuthMode('pat')}
+ >
+ Personal access token
+
+ Use a PAT from a bot or service account.
+
+
+ setAdoAuthMode('entra')}
+ >
+
+ Microsoft Entra service principal
+
+
+ Use short-lived service-principal tokens.
+
+
+ setAdoAuthMode('delegated')}
+ >
+
+ Connect with your Microsoft account
+
+
+ Use a delegated Azure DevOps account.
+
+
+
+ );
+
+ 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({