From 44b0535ddf3327df39840b822b7794f32784617d Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 22 Jul 2026 15:11:16 -0400 Subject: [PATCH 1/3] fix: surface permission sync issues requiring user action --- .../src/ee/accountPermissionSyncer.test.ts | 132 +++++++++++++++++- .../backend/src/ee/accountPermissionSyncer.ts | 49 +++++-- .../migration.sql | 7 + packages/db/prisma/schema.prisma | 12 +- .../components/banners/bannerResolver.test.ts | 16 +++ .../components/banners/bannerResolver.tsx | 15 +- .../banners/permissionSyncBanner.test.tsx | 98 +++++++++++++ .../banners/permissionSyncBanner.tsx | 68 ++++++--- packages/web/src/app/(app)/layout.tsx | 5 + .../ee/permissionSyncStatus/api.test.ts | 98 +++++++++++++ .../(server)/ee/permissionSyncStatus/api.ts | 33 ++++- packages/web/src/auth.ts | 22 ++- packages/web/src/ee/features/sso/actions.ts | 9 +- .../linkedAccountProviderCard.test.tsx | 96 +++++++++++++ .../components/linkedAccountProviderCard.tsx | 49 +++++-- .../web/src/features/workerApi/actions.ts | 21 +-- .../features/workerApi/client.server.test.ts | 38 +++++ .../src/features/workerApi/client.server.ts | 26 ++++ 18 files changed, 721 insertions(+), 73 deletions(-) create mode 100644 packages/db/prisma/migrations/20260722144824_add_account_permission_sync_issue/migration.sql create mode 100644 packages/web/src/app/(app)/components/banners/permissionSyncBanner.test.tsx create mode 100644 packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.test.ts create mode 100644 packages/web/src/ee/features/sso/components/linkedAccountProviderCard.test.tsx create mode 100644 packages/web/src/features/workerApi/client.server.test.ts create mode 100644 packages/web/src/features/workerApi/client.server.ts diff --git a/packages/backend/src/ee/accountPermissionSyncer.test.ts b/packages/backend/src/ee/accountPermissionSyncer.test.ts index 9636b34c9..2e46a2be3 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.test.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, test } from 'vitest'; -import { classifyPermissionSyncFailure } from './accountPermissionSyncer.js'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + hasEntitlement: vi.fn(), +})); + +vi.mock('../entitlements.js', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); + +import { AccountPermissionSyncer, classifyPermissionSyncFailure } from './accountPermissionSyncer.js'; import { PermissionSyncUpstreamError, type PermissionSyncUpstreamErrorKind, @@ -22,6 +31,49 @@ const upstreamError = ( operation: 'list_accessible_repositories', }); +const createSyncerHarness = (syncError?: Error, permissionCount = 95) => { + const account = { + id: 'account_1', + providerId: 'bitbucket-server', + user: { email: 'user@example.com' }, + }; + const db = { + accountPermissionSyncJob: { + update: vi.fn().mockResolvedValue({ account }), + }, + accountToRepoPermission: { + deleteMany: vi.fn().mockResolvedValue({ count: permissionCount }), + }, + account: { + update: vi.fn().mockResolvedValue(account), + }, + $transaction: vi.fn((queries: Array>) => Promise.all(queries)), + }; + const syncAccountPermissions = syncError + ? vi.fn().mockRejectedValue(syncError) + : vi.fn().mockResolvedValue(undefined); + const syncer = Object.create(AccountPermissionSyncer.prototype) as { + db: typeof db; + syncAccountPermissions: typeof syncAccountPermissions; + runJob(job: { data: { jobId: string } }): Promise; + onJobCompleted(job: { data: { jobId: string } }): Promise; + }; + syncer.db = db; + syncer.syncAccountPermissions = syncAccountPermissions; + + return { + account, + db, + job: { data: { jobId: 'job_1' } }, + syncer, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.hasEntitlement.mockResolvedValue(true); +}); + describe('classifyPermissionSyncFailure', () => { test('fails closed for invalid_grant', () => { expect(classifyPermissionSyncFailure(tokenRefreshError('invalid_grant', 400))).toEqual({ @@ -75,3 +127,79 @@ describe('classifyPermissionSyncFailure', () => { }); }); }); + +describe('permission sync issue lifecycle', () => { + test('atomically records a reauthentication issue when invalid_grant clears permissions', async () => { + const error = tokenRefreshError('invalid_grant', 400); + const { db, job, syncer } = createSyncerHarness(error); + + await expect(syncer.runJob(job)).rejects.toBe(error); + + expect(db.accountToRepoPermission.deleteMany).toHaveBeenCalledWith({ + where: { accountId: 'account_1' }, + }); + expect(db.account.update).toHaveBeenCalledWith({ + where: { id: 'account_1' }, + data: { + permissionSyncIssue: 'REAUTHENTICATION_REQUIRED', + permissionSyncIssueAt: expect.any(Date), + }, + }); + expect(db.$transaction).toHaveBeenCalledOnce(); + }); + + test('records an issue when permissions were already cleared by an earlier attempt', async () => { + const error = tokenRefreshError('invalid_grant', 400); + const { db, job, syncer } = createSyncerHarness(error, 0); + + await expect(syncer.runJob(job)).rejects.toBe(error); + + expect(db.account.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + permissionSyncIssue: 'REAUTHENTICATION_REQUIRED', + }), + })); + expect(db.$transaction).toHaveBeenCalledOnce(); + }); + + test('records an insufficient-scope issue for scope failures', async () => { + const error = upstreamError('insufficient_scope'); + const { db, job, syncer } = createSyncerHarness(error); + + await expect(syncer.runJob(job)).rejects.toBe(error); + + expect(db.account.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + permissionSyncIssue: 'INSUFFICIENT_SCOPE', + }), + })); + }); + + test('does not record an issue or clear permissions for a transient refresh failure', async () => { + const error = tokenRefreshError('transient', 500); + const { db, job, syncer } = createSyncerHarness(error); + + await expect(syncer.runJob(job)).rejects.toBe(error); + + expect(db.accountToRepoPermission.deleteMany).not.toHaveBeenCalled(); + expect(db.account.update).not.toHaveBeenCalled(); + expect(db.$transaction).not.toHaveBeenCalled(); + }); + + test('clears the action-required issue after a successful permission sync', async () => { + const { db, job, syncer } = createSyncerHarness(); + + await syncer.onJobCompleted(job); + + expect(db.accountPermissionSyncJob.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + account: { + update: expect.objectContaining({ + permissionSyncIssue: null, + permissionSyncIssueAt: null, + }), + }, + }), + })); + }); +}); diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index b507051d0..96c867b93 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -1,5 +1,11 @@ import * as Sentry from "@sentry/node"; -import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db"; +import { + PrismaClient, + AccountPermissionSyncIssue, + AccountPermissionSyncJobStatus, + Account, + PermissionSyncSource, +} from "@sourcebot/db"; import { env, createLogger, getIdentityProviderConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; import { hasEntitlement } from "../entitlements.js"; import { ensureFreshAccountToken, TokenRefreshError } from "./tokenRefresh.js"; @@ -46,10 +52,22 @@ export type PermissionCleanupDecision = action: 'preserve_permissions'; }; -const PERMISSION_CLEANUP_REASON_MESSAGES: Record = { - oauth_invalid_grant: 'OAuth invalid_grant', - upstream_credential_rejected: 'upstream credential rejection', - upstream_insufficient_scope: 'insufficient OAuth scope', +const PERMISSION_CLEANUP_DETAILS: Record = { + oauth_invalid_grant: { + message: 'OAuth invalid_grant', + issue: AccountPermissionSyncIssue.REAUTHENTICATION_REQUIRED, + }, + upstream_credential_rejected: { + message: 'upstream credential rejection', + issue: AccountPermissionSyncIssue.REAUTHENTICATION_REQUIRED, + }, + upstream_insufficient_scope: { + message: 'insufficient OAuth scope', + issue: AccountPermissionSyncIssue.INSUFFICIENT_SCOPE, + }, }; export const classifyPermissionSyncFailure = (error: unknown): PermissionCleanupDecision => { @@ -238,12 +256,21 @@ export class AccountPermissionSyncer { const cleanupDecision = classifyPermissionSyncFailure(error); if (cleanupDecision.action === 'clear_permissions') { - const { count } = await this.db.accountToRepoPermission.deleteMany({ - where: { accountId: account.id }, - }); + const details = PERMISSION_CLEANUP_DETAILS[cleanupDecision.reason]; + const [{ count }] = await this.db.$transaction([ + this.db.accountToRepoPermission.deleteMany({ + where: { accountId: account.id }, + }), + this.db.account.update({ + where: { id: account.id }, + data: { + permissionSyncIssue: details.issue, + permissionSyncIssueAt: new Date(), + }, + }), + ]); const message = error instanceof Error ? error.message : String(error); - const reason = PERMISSION_CLEANUP_REASON_MESSAGES[cleanupDecision.reason]; - logger.warn(`Cleared ${count} permission row(s) for account ${account.id} (user ${account.user.email ?? 'unknown'}) — fail-closed cleanup triggered by ${reason}: ${message}`); + logger.warn(`Cleared ${count} permission row(s) for account ${account.id} (user ${account.user.email ?? 'unknown'}) — fail-closed cleanup triggered by ${details.message}: ${message}`); } throw error; } @@ -451,6 +478,8 @@ export class AccountPermissionSyncer { account: { update: { permissionSyncedAt: new Date(), + permissionSyncIssue: null, + permissionSyncIssueAt: null, }, }, completedAt: new Date(), diff --git a/packages/db/prisma/migrations/20260722144824_add_account_permission_sync_issue/migration.sql b/packages/db/prisma/migrations/20260722144824_add_account_permission_sync_issue/migration.sql new file mode 100644 index 000000000..86cd9b315 --- /dev/null +++ b/packages/db/prisma/migrations/20260722144824_add_account_permission_sync_issue/migration.sql @@ -0,0 +1,7 @@ +-- CreateEnum +CREATE TYPE "AccountPermissionSyncIssue" AS ENUM ('REAUTHENTICATION_REQUIRED', 'INSUFFICIENT_SCOPE'); + +-- AlterTable +ALTER TABLE "Account" +ADD COLUMN "permissionSyncIssue" "AccountPermissionSyncIssue", +ADD COLUMN "permissionSyncIssueAt" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 1853ec5ce..e43f2887b 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -562,6 +562,11 @@ enum PermissionSyncSource { REPO_DRIVEN } +enum AccountPermissionSyncIssue { + REAUTHENTICATION_REQUIRED + INSUFFICIENT_SCOPE +} + model AccountToRepoPermission { createdAt DateTime @default(now()) @@ -607,7 +612,12 @@ model Account { permissionSyncJobs AccountPermissionSyncJob[] permissionSyncedAt DateTime? - /// Set when an OAuth token refresh fails and the account needs to be re-linked by the user. + /// Set when permission syncing fails closed and user action is required. + /// Cleared after a subsequent permission sync completes successfully. + permissionSyncIssue AccountPermissionSyncIssue? + permissionSyncIssueAt DateTime? + + /// Last OAuth token refresh failure, retained for diagnostics. /// Cleared when the user successfully re-authenticates. tokenRefreshErrorMessage String? diff --git a/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts b/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts index 399d3f19b..8c738c078 100644 --- a/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts +++ b/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts @@ -78,6 +78,7 @@ const makeContext = (overrides: Partial = {}): BannerContext => ( offlineLicense: null, hasPermissionSyncEntitlement: false, hasPendingFirstSync: false, + permissionSyncIssues: [], dismissals: {}, today: TODAY, now: NOW, @@ -100,6 +101,21 @@ describe('resolveActiveBanner', () => { expect(result?.id).toBe('permissionSync'); }); + test('shows permission sync banner when an account requires reauthentication', () => { + const result = resolveActiveBanner(makeContext({ + hasPermissionSyncEntitlement: true, + permissionSyncIssues: [{ + accountId: 'account_1', + providerId: 'bitbucket-server', + providerType: 'bitbucket-server', + reason: 'REAUTHENTICATION_REQUIRED', + occurredAt: NOW.toISOString(), + }], + })); + expect(result?.id).toBe('permissionSync'); + expect(result?.dismissible).toBe(false); + }); + test('license expired outranks permission sync', () => { const result = resolveActiveBanner(makeContext({ license: makeLicense({ status: 'canceled' }), diff --git a/packages/web/src/app/(app)/components/banners/bannerResolver.tsx b/packages/web/src/app/(app)/components/banners/bannerResolver.tsx index 027d9d989..879abe251 100644 --- a/packages/web/src/app/(app)/components/banners/bannerResolver.tsx +++ b/packages/web/src/app/(app)/components/banners/bannerResolver.tsx @@ -15,6 +15,7 @@ import { InvoicePastDueBanner } from "./invoicePastDueBanner"; import { ServicePingFailedBanner } from "./servicePingFailedBanner"; import { TrialBanner } from "./trialBanner"; import { UpgradeAvailableBanner } from "./upgradeAvailableBanner"; +import type { PermissionSyncStatusResponse } from "@/app/api/(server)/ee/permissionSyncStatus/api"; // Mirrors the value in `lighthouse: lambda/serviceError.ts` and the gating // constant in `packages/shared/src/entitlements.ts`. @@ -28,6 +29,7 @@ export interface BannerContext { offlineLicense: OfflineLicenseMetadata | null; hasPermissionSyncEntitlement: boolean; hasPendingFirstSync: boolean; + permissionSyncIssues: PermissionSyncStatusResponse['issues']; dismissals: Partial>; today: string; now: Date; @@ -155,14 +157,23 @@ function buildCandidates(ctx: BannerContext): BannerDescriptor[] { }); } - if (ctx.hasPermissionSyncEntitlement && ctx.hasPendingFirstSync) { + if ( + ctx.hasPermissionSyncEntitlement && + (ctx.hasPendingFirstSync || ctx.permissionSyncIssues.length > 0) + ) { banners.push({ id: 'permissionSync', priority: BannerPriority.PERMISSION_SYNC, dismissible: false, audience: 'everyone', render: (props) => ( - + ), }); } diff --git a/packages/web/src/app/(app)/components/banners/permissionSyncBanner.test.tsx b/packages/web/src/app/(app)/components/banners/permissionSyncBanner.test.tsx new file mode 100644 index 000000000..ba348b600 --- /dev/null +++ b/packages/web/src/app/(app)/components/banners/permissionSyncBanner.test.tsx @@ -0,0 +1,98 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { PermissionSyncStatusResponse } from '@/app/api/(server)/ee/permissionSyncStatus/api'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +vi.mock('@/app/api/(client)/client', () => ({ + getPermissionSyncStatus: vi.fn(), +})); + +vi.mock('@/lib/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + getAuthProviderInfo: () => ({ displayName: 'Bitbucket Server' }), + unwrapServiceError: (value: unknown) => value, +})); + +vi.mock('./bannerShell', () => ({ + BannerShell: ({ title, description, action }: { + title: ReactNode; + description?: ReactNode; + action?: ReactNode; + }) => ( +
+
{title}
+
{description}
+
{action}
+
+ ), +})); + +const { PermissionSyncBanner } = await import('./permissionSyncBanner'); + +const renderBanner = (initialStatus: PermissionSyncStatusResponse) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +}; + +afterEach(() => { + cleanup(); +}); + +describe('PermissionSyncBanner', () => { + test('explains that repository access was disabled when reauthentication is required', () => { + renderBanner({ + hasPendingFirstSync: false, + issues: [{ + accountId: 'account_1', + providerId: 'bitbucket-server-corp', + providerType: 'bitbucket-server', + reason: 'REAUTHENTICATION_REQUIRED', + occurredAt: '2026-07-22T12:00:00.000Z', + }], + }); + + expect(screen.getByText('Repository access from Bitbucket Server needs attention.')).toBeTruthy(); + expect(screen.getByText(/Private repository access has been disabled until you reconnect/)).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Review linked accounts' }).getAttribute('href')) + .toBe('/settings/linked-accounts'); + }); + + test('provides scope-specific remediation', () => { + renderBanner({ + hasPendingFirstSync: false, + issues: [{ + accountId: 'account_1', + providerId: 'github', + providerType: 'github', + reason: 'INSUFFICIENT_SCOPE', + occurredAt: null, + }], + }); + + expect(screen.getByText(/required OAuth scope was not granted/)).toBeTruthy(); + expect(screen.getByText(/until you reauthorize/)).toBeTruthy(); + }); + + test('retains the existing initial-sync progress state', () => { + renderBanner({ hasPendingFirstSync: true, issues: [] }); + + expect(screen.getByText(/Syncing repository access with Sourcebot/)).toBeTruthy(); + }); +}); diff --git a/packages/web/src/app/(app)/components/banners/permissionSyncBanner.tsx b/packages/web/src/app/(app)/components/banners/permissionSyncBanner.tsx index 752f49b43..f16a2c476 100644 --- a/packages/web/src/app/(app)/components/banners/permissionSyncBanner.tsx +++ b/packages/web/src/app/(app)/components/banners/permissionSyncBanner.tsx @@ -1,52 +1,86 @@ 'use client'; -import { Loader2, Info } from "lucide-react"; +import { AlertTriangle, Info, Loader2 } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; -import { unwrapServiceError } from "@/lib/utils"; +import { getAuthProviderInfo, unwrapServiceError } from "@/lib/utils"; import { getPermissionSyncStatus } from "@/app/api/(client)/client"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { usePrevious } from "@uidotdev/usehooks"; import { BannerShell } from "./bannerShell"; import type { BannerProps } from "./types"; +import type { PermissionSyncStatusResponse } from "@/app/api/(server)/ee/permissionSyncStatus/api"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; const POLL_INTERVAL_MS = 5000; +const ISSUE_POLL_INTERVAL_MS = 30000; interface PermissionSyncBannerProps extends BannerProps { - initialHasPendingFirstSync: boolean; + initialStatus: PermissionSyncStatusResponse; } -export function PermissionSyncBanner({ id, dismissible, initialHasPendingFirstSync }: PermissionSyncBannerProps) { +export function PermissionSyncBanner({ id, dismissible, initialStatus }: PermissionSyncBannerProps) { const router = useRouter(); - const { data: hasPendingFirstSync, isError, isPending } = useQuery({ + const { data: status, isError, isPending } = useQuery({ queryKey: ["permissionSyncStatus"], queryFn: () => unwrapServiceError(getPermissionSyncStatus()), - select: (data) => { - return data.hasPendingFirstSync; - }, refetchInterval: (query) => { - const hasPendingFirstSync = query.state.data?.hasPendingFirstSync; - return hasPendingFirstSync ? POLL_INTERVAL_MS : false; + if (query.state.data?.hasPendingFirstSync) { + return POLL_INTERVAL_MS; + } + return query.state.data?.issues.length ? ISSUE_POLL_INTERVAL_MS : false; }, - initialData: { - hasPendingFirstSync: initialHasPendingFirstSync, - } + initialData: initialStatus, }); - const previousHasPendingFirstSync = usePrevious(hasPendingFirstSync); + const previousHasPendingFirstSync = usePrevious(status.hasPendingFirstSync); + const previousIssueCount = usePrevious(status.issues.length); useEffect(() => { - if (previousHasPendingFirstSync === true && hasPendingFirstSync === false) { + const initialSyncCompleted = + previousHasPendingFirstSync === true && status.hasPendingFirstSync === false; + const issueResolved = previousIssueCount !== undefined && + previousIssueCount > 0 && status.issues.length === 0; + if (initialSyncCompleted || issueResolved) { router.refresh(); } - }, [hasPendingFirstSync, previousHasPendingFirstSync, router]); + }, [status.hasPendingFirstSync, status.issues.length, previousHasPendingFirstSync, previousIssueCount, router]); if (isError || isPending) { return null; } - if (!hasPendingFirstSync) { + const issue = status.issues[0]; + if (issue) { + const providerName = status.issues.length === 1 + ? getAuthProviderInfo(issue.providerType).displayName + : `${status.issues.length} code hosts`; + const requiresAdditionalScope = status.issues.some(({ reason }) => reason === 'INSUFFICIENT_SCOPE'); + const description = status.issues.length > 1 + ? "Sourcebot could not verify permissions for multiple linked accounts. Private repository access has been disabled until you review and reauthorize the affected accounts." + : requiresAdditionalScope + ? "Sourcebot could not verify your repository permissions because the required OAuth scope was not granted. Private repository access has been disabled until you reauthorize the affected account." + : "Sourcebot could not verify your repository permissions. Private repository access has been disabled until you reconnect the affected account."; + + return ( + } + title={`Repository access from ${providerName} needs attention.`} + description={description} + action={( + + )} + /> + ); + } + + if (!status.hasPendingFirstSync) { return null; } diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index c8f7b6918..7f5975c5d 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -165,6 +165,10 @@ export default async function Layout(props: LayoutProps) { permissionSyncStatus !== null && !isServiceError(permissionSyncStatus) ? permissionSyncStatus.hasPendingFirstSync : false; + const permissionSyncIssues = + permissionSyncStatus !== null && !isServiceError(permissionSyncStatus) + ? permissionSyncStatus.issues + : []; const offlineLicense = getOfflineLicenseMetadata(); const license = offlineLicense @@ -198,6 +202,7 @@ export default async function Layout(props: LayoutProps) { offlineLicense={offlineLicense} hasPermissionSyncEntitlement={hasPermissionSyncEntitlement} hasPendingFirstSync={hasPendingFirstSync} + permissionSyncIssues={permissionSyncIssues} currentVersion={SOURCEBOT_VERSION} latestVersion={latestVersion} /> diff --git a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.test.ts b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.test.ts new file mode 100644 index 000000000..73edcde78 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, + getEntitlements: vi.fn(), +})); + +vi.mock('@/middleware/withAuth', () => ({ + withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), +})); + +vi.mock('@/lib/entitlements', () => ({ + getEntitlements: mocks.getEntitlements, +})); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => ({ error: vi.fn() }), + env: { PERMISSION_SYNC_ENABLED: 'true' }, + PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: [ + 'github', + 'gitlab', + 'bitbucket-cloud', + 'bitbucket-server', + ], +})); + +const { getPermissionSyncStatus } = await import('./api'); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getEntitlements.mockResolvedValue(['permission-syncing']); +}); + +describe('getPermissionSyncStatus', () => { + test('returns pending first-sync state and structured account issues for the authenticated user', async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: 'account_pending', + providerId: 'github', + providerType: 'github', + permissionSyncedAt: null, + permissionSyncIssue: null, + permissionSyncIssueAt: null, + permissionSyncJobs: [{ status: 'PENDING' }], + }, + { + id: 'account_action_required', + providerId: 'bitbucket-server-corp', + providerType: 'bitbucket-server', + permissionSyncedAt: new Date('2026-07-01T00:00:00Z'), + permissionSyncIssue: 'REAUTHENTICATION_REQUIRED', + permissionSyncIssueAt: new Date('2026-07-22T12:00:00Z'), + permissionSyncJobs: [{ status: 'FAILED' }], + }, + ]); + mocks.authContext = { + user: { id: 'user_1' }, + prisma: { account: { findMany } }, + }; + + await expect(getPermissionSyncStatus()).resolves.toEqual({ + hasPendingFirstSync: true, + issues: [{ + accountId: 'account_action_required', + providerId: 'bitbucket-server-corp', + providerType: 'bitbucket-server', + reason: 'REAUTHENTICATION_REQUIRED', + occurredAt: '2026-07-22T12:00:00.000Z', + }], + }); + expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ userId: 'user_1' }), + })); + }); + + test('returns an issue even when the account has no issue timestamp', async () => { + mocks.authContext = { + user: { id: 'user_1' }, + prisma: { + account: { + findMany: vi.fn().mockResolvedValue([{ + id: 'account_1', + providerId: 'gitlab', + providerType: 'gitlab', + permissionSyncedAt: new Date('2026-07-01T00:00:00Z'), + permissionSyncIssue: 'INSUFFICIENT_SCOPE', + permissionSyncIssueAt: null, + permissionSyncJobs: [{ status: 'FAILED' }], + }]), + }, + }, + }; + + await expect(getPermissionSyncStatus()).resolves.toMatchObject({ + issues: [{ reason: 'INSUFFICIENT_SCOPE', occurredAt: null }], + }); + }); +}); diff --git a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts index 14238f6c0..69b808853 100644 --- a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts +++ b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts @@ -4,18 +4,25 @@ import { ServiceError } from "@/lib/serviceError"; import { withAuth } from "@/middleware/withAuth"; import { getEntitlements } from "@/lib/entitlements"; import { env, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; -import { AccountPermissionSyncJobStatus } from "@sourcebot/db"; +import { AccountPermissionSyncJobStatus, type AccountPermissionSyncIssue } from "@sourcebot/db"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; import { sew } from "@/middleware/sew"; export interface PermissionSyncStatusResponse { hasPendingFirstSync: boolean; + issues: Array<{ + accountId: string; + providerId: string; + providerType: string; + reason: AccountPermissionSyncIssue; + occurredAt: string | null; + }>; } /** - * Returns whether a user has a account that has it's permissions - * synced for the first time. + * Returns initial-sync progress and action-required permission sync issues + * for the authenticated user's linked accounts. */ export const getPermissionSyncStatus = async (): Promise => sew(async () => withAuth(async ({ prisma, user }) => { @@ -34,7 +41,13 @@ export const getPermissionSyncStatus = async (): Promise 0 && activeStatuses.includes(account.permissionSyncJobs[0].status))) - ) + ); - return { hasPendingFirstSync } satisfies PermissionSyncStatusResponse; + const issues = accounts.flatMap(account => account.permissionSyncIssue === null ? [] : [{ + accountId: account.id, + providerId: account.providerId, + providerType: account.providerType, + reason: account.permissionSyncIssue, + occurredAt: account.permissionSyncIssueAt?.toISOString() ?? null, + }]); + + return { hasPendingFirstSync, issues } satisfies PermissionSyncStatusResponse; }) ) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index ac8bba89f..8f1932c0c 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -4,7 +4,7 @@ import NextAuth, { DefaultSession, Session, User as AuthJsUser } from "next-auth import Credentials from "next-auth/providers/credentials" import EmailProvider from "next-auth/providers/nodemailer"; import { __unsafePrisma } from "@/prisma"; -import { env, getSMTPConnectionURL } from "@sourcebot/shared"; +import { createLogger, env, getSMTPConnectionURL } from "@sourcebot/shared"; import { User } from '@sourcebot/db'; import 'next-auth/jwt'; import type { Provider } from "next-auth/providers"; @@ -23,8 +23,10 @@ import { captureEvent } from '@/lib/posthog'; import { isEmailCodeLoginEnabled, isCredentialsLoginEnabled } from '@sourcebot/shared' import { onCreateUser } from './features/membership/onCreateUser'; import { setSentryUser } from './lib/sentryUser'; +import { requestAccountPermissionSync } from './features/workerApi/client.server'; export const runtime = 'nodejs'; +const logger = createLogger('auth'); export type IdentityProvider = { /** Provider type (e.g., 'github', 'gitlab') — used to pick icon / display defaults. */ @@ -203,7 +205,7 @@ const nextAuthResult = NextAuth(async () => ({ ) { const issuerUrl = await getIssuerUrlForProviderId(account.provider); - await __unsafePrisma.account.update({ + const updatedAccount = await __unsafePrisma.account.update({ where: { providerId_providerAccountId: { providerId: account.provider, @@ -221,7 +223,21 @@ const nextAuthResult = NextAuth(async () => ({ // Clear any token refresh error since the user has successfully re-authenticated. tokenRefreshErrorMessage: null, }) - }) + }); + + if ( + updatedAccount.permissionSyncIssue !== null && + env.PERMISSION_SYNC_ENABLED === 'true' + ) { + try { + if (await hasEntitlement('permission-syncing')) { + await requestAccountPermissionSync(updatedAccount.id); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Failed to schedule permission sync after reauthentication for account ${updatedAccount.id}: ${message}`); + } + } } if (user.id) { diff --git a/packages/web/src/ee/features/sso/actions.ts b/packages/web/src/ee/features/sso/actions.ts index 17ba89ade..f5734c71b 100644 --- a/packages/web/src/ee/features/sso/actions.ts +++ b/packages/web/src/ee/features/sso/actions.ts @@ -4,7 +4,7 @@ import { sew } from "@/middleware/sew"; import { OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants"; import { withAuth } from "@/middleware/withAuth"; import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; -import { OrgRole } from "@sourcebot/db"; +import { OrgRole, type AccountPermissionSyncIssue } from "@sourcebot/db"; import { hasEntitlement } from "@/lib/entitlements"; import { createLogger, doesIdpSupportPermissionSyncing, env, getIdentityProviderConfig, getIdentityProviderConfigs } from "@sourcebot/shared"; import { cookies } from "next/headers"; @@ -22,7 +22,7 @@ export type LinkedAccount = { // Present when isLinked = true accountId?: string; providerAccountId?: string; - error?: string; + permissionSyncIssue?: AccountPermissionSyncIssue; // From config (only meaningful for account_linking providers) isAccountLinkingProvider: boolean; required: boolean; @@ -40,7 +40,7 @@ export const getLinkedAccounts = async () => sew(() => providerType: true, providerId: true, providerAccountId: true, - tokenRefreshErrorMessage: true, + permissionSyncIssue: true, }, }); @@ -62,7 +62,7 @@ export const getLinkedAccounts = async () => sew(() => isLinked: true, accountId: account.id, providerAccountId: account.providerAccountId, - error: account.tokenRefreshErrorMessage ?? undefined, + permissionSyncIssue: account.permissionSyncIssue ?? undefined, isAccountLinkingProvider: isAccountLinking, required: isAccountLinking ? (providerConfig?.accountLinkingRequired ?? false) : false, supportsPermissionSync: permissionSyncEnabled && doesIdpSupportPermissionSyncing(account.providerType), @@ -118,4 +118,3 @@ export const skipOptionalProvidersLink = async () => sew(async () => { }); return true; }); - diff --git a/packages/web/src/ee/features/sso/components/linkedAccountProviderCard.test.tsx b/packages/web/src/ee/features/sso/components/linkedAccountProviderCard.test.tsx new file mode 100644 index 000000000..beb2c091b --- /dev/null +++ b/packages/web/src/ee/features/sso/components/linkedAccountProviderCard.test.tsx @@ -0,0 +1,96 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { LinkedAccount } from '@/ee/features/sso/actions'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +vi.mock('next-auth/react', () => ({ + signIn: vi.fn(), +})); + +vi.mock('@/components/hooks/use-toast', () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock('@/ee/features/sso/actions', () => ({ + unlinkLinkedAccountProvider: vi.fn(), +})); + +vi.mock('@/features/workerApi/actions', () => ({ + triggerAccountPermissionSync: vi.fn(), +})); + +vi.mock('@/app/api/(client)/client', () => ({ + getAccountSyncStatus: vi.fn(), +})); + +vi.mock('@/lib/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + getAuthProviderInfo: () => ({ displayName: 'Bitbucket Server', icon: null }), + isServiceError: () => false, + unwrapServiceError: (value: unknown) => value, +})); + +vi.mock('./providerIcon', () => ({ + ProviderIcon: () =>
, +})); + +const { LinkedAccountProviderCard } = await import('./linkedAccountProviderCard'); + +const linkedAccount: LinkedAccount = { + providerId: 'bitbucket-server-corp', + providerType: 'bitbucket-server', + isLinked: true, + accountId: 'account_1', + providerAccountId: 'user_1', + isAccountLinkingProvider: true, + required: false, + supportsPermissionSync: true, +}; + +const renderCard = (account: LinkedAccount) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +}; + +afterEach(() => { + cleanup(); +}); + +describe('LinkedAccountProviderCard permission sync health', () => { + test('shows a healthy linked account as connected', () => { + renderCard(linkedAccount); + + expect(screen.getByText('Connected')).toBeTruthy(); + expect(screen.queryByText('Needs attention')).toBeNull(); + }); + + test('shows reconnect guidance when repository permissions were cleared', () => { + renderCard({ + ...linkedAccount, + permissionSyncIssue: 'REAUTHENTICATION_REQUIRED', + }); + + expect(screen.getByText('Needs attention')).toBeTruthy(); + expect(screen.getByText('Reconnect this account to restore repository access.')).toBeTruthy(); + expect(screen.queryByText('Connected')).toBeNull(); + }); + + test('shows scope-specific guidance when the OAuth grant is insufficient', () => { + renderCard({ + ...linkedAccount, + permissionSyncIssue: 'INSUFFICIENT_SCOPE', + }); + + expect(screen.getByText('Additional permissions are required to restore repository access.')).toBeTruthy(); + }); +}); diff --git a/packages/web/src/ee/features/sso/components/linkedAccountProviderCard.tsx b/packages/web/src/ee/features/sso/components/linkedAccountProviderCard.tsx index 544b88fa8..469edba1b 100644 --- a/packages/web/src/ee/features/sso/components/linkedAccountProviderCard.tsx +++ b/packages/web/src/ee/features/sso/components/linkedAccountProviderCard.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from "react"; -import { getAuthProviderInfo, unwrapServiceError } from "@/lib/utils"; +import { cn, getAuthProviderInfo, unwrapServiceError } from "@/lib/utils"; import { AlertCircle, ArrowUpRight, ChevronDown, RefreshCw, Unlink } from "lucide-react"; import { ProviderIcon } from "./providerIcon"; import { LinkedAccount } from "@/ee/features/sso/actions"; @@ -40,6 +40,13 @@ export function LinkedAccountProviderCard({ const providerInfo = getAuthProviderInfo(linkedAccount.providerType); const displayName = linkedAccount.displayName ?? providerInfo.displayName; + const needsAttention = linkedAccount.permissionSyncIssue !== undefined; + const issueDescription = linkedAccount.permissionSyncIssue === 'INSUFFICIENT_SCOPE' + ? 'Additional permissions are required to restore repository access.' + : 'Reconnect this account to restore repository access.'; + const reconnectLabel = linkedAccount.permissionSyncIssue === 'INSUFFICIENT_SCOPE' + ? 'Reauthorize Account' + : 'Reconnect Account'; const { data: syncStatusData } = useQuery({ queryKey: ["accountSyncStatus", syncJobId], @@ -86,7 +93,9 @@ export function LinkedAccountProviderCard({ }; const handleRefreshPermissions = async () => { - if (!linkedAccount.accountId) return; + if (!linkedAccount.accountId) { + return; + } try { const result = await triggerAccountPermissionSync(linkedAccount.accountId); if (isServiceError(result)) { @@ -128,10 +137,10 @@ export function LinkedAccountProviderCard({ {linkedAccount.providerAccountId} )} - {linkedAccount.error && ( - + {needsAttention && ( + - Token refresh failed — please reconnect + {issueDescription} )}
@@ -142,18 +151,36 @@ export function LinkedAccountProviderCard({ - {!isBusy && } - {isDisconnecting ? "Disconnecting..." : isSyncing ? "Syncing..." : "Connected"} + {!isBusy && ( + + )} + {isDisconnecting + ? "Disconnecting..." + : isSyncing + ? "Syncing..." + : needsAttention + ? "Needs attention" + : "Connected"} - {linkedAccount.supportsPermissionSync && linkedAccount.accountId && ( - - - Refresh Permissions + {needsAttention && ( + + + {reconnectLabel} )} + {linkedAccount.supportsPermissionSync && + linkedAccount.accountId && ( + + + Refresh Permissions + + )} sew(() => export const triggerAccountPermissionSync = async (accountId: string) => sew(() => withAuth(({ role }) => withMinimumOrgRole(role, OrgRole.MEMBER, async () => { - const response = await fetch(`${WORKER_API_URL}/api/trigger-account-permission-sync`, { - method: 'POST', - body: JSON.stringify({ accountId }), - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { + try { + return await requestAccountPermissionSync(accountId); + } catch { return unexpectedError('Failed to trigger account permission sync'); } - - const data = await response.json(); - const schema = z.object({ - jobId: z.string(), - }); - return schema.parse(data); }) ) ); @@ -108,4 +97,4 @@ export const addGithubRepo = async (owner: string, repo: string) => sew(() => }); return schema.parse(data); }) -); \ No newline at end of file +); diff --git a/packages/web/src/features/workerApi/client.server.test.ts b/packages/web/src/features/workerApi/client.server.test.ts new file mode 100644 index 000000000..8096f79d9 --- /dev/null +++ b/packages/web/src/features/workerApi/client.server.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); +vi.mock('@sourcebot/shared', () => ({ + env: { WORKER_API_URL: 'http://worker.example.com' }, +})); + +const { requestAccountPermissionSync } = await import('./client.server'); + +beforeEach(() => { + vi.unstubAllGlobals(); +}); + +describe('requestAccountPermissionSync', () => { + test('schedules an account permission sync with the worker', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response( + JSON.stringify({ jobId: 'job_1' }), + { status: 200 }, + )); + vi.stubGlobal('fetch', fetchMock); + + await expect(requestAccountPermissionSync('account_1')).resolves.toEqual({ jobId: 'job_1' }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://worker.example.com/api/trigger-account-permission-sync', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ accountId: 'account_1' }), + }), + ); + }); + + test('rejects when the worker does not accept the sync request', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 503 }))); + + await expect(requestAccountPermissionSync('account_1')) + .rejects.toThrow('Worker rejected account permission sync with HTTP 503.'); + }); +}); diff --git a/packages/web/src/features/workerApi/client.server.ts b/packages/web/src/features/workerApi/client.server.ts new file mode 100644 index 000000000..79b110d13 --- /dev/null +++ b/packages/web/src/features/workerApi/client.server.ts @@ -0,0 +1,26 @@ +import 'server-only'; + +import { env } from '@sourcebot/shared'; +import { z } from 'zod'; + +const accountPermissionSyncResponseSchema = z.object({ + jobId: z.string(), +}); +const WORKER_REQUEST_TIMEOUT_MS = 5000; + +export const requestAccountPermissionSync = async (accountId: string): Promise<{ jobId: string }> => { + const response = await fetch(`${env.WORKER_API_URL}/api/trigger-account-permission-sync`, { + method: 'POST', + body: JSON.stringify({ accountId }), + headers: { + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(WORKER_REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`Worker rejected account permission sync with HTTP ${response.status}.`); + } + + return accountPermissionSyncResponseSchema.parse(await response.json()); +}; From c1496786854a5fddf3254509972e71a188e22717 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 22 Jul 2026 15:12:33 -0400 Subject: [PATCH 2/3] chore: add changelog entry for permission sync recovery UX --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4678376f3..d528f08d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - [EE] Preserved cached repository permissions when OAuth token refresh fails because of a transient code host outage. [#1481](https://github.com/sourcebot-dev/sourcebot/pull/1481) - [EE] Classified code host permission sync failures by provider context before clearing cached repository permissions. [#1482](https://github.com/sourcebot-dev/sourcebot/pull/1482) +- [EE] Added action-required warnings and guided recovery when permission syncing clears cached repository access. [#1484](https://github.com/sourcebot-dev/sourcebot/pull/1484) ## [5.1.3] - 2026-07-20 From 2c03c15b0d8a7ba59374814cc88510e6ffb24076 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 22 Jul 2026 16:18:28 -0400 Subject: [PATCH 3/3] Improve permission sync recovery feedback --- .../components/banners/bannerResolver.test.ts | 1 + .../banners/permissionSyncBanner.test.tsx | 28 ++++++++-- .../banners/permissionSyncBanner.tsx | 54 +++++++++---------- .../ee/permissionSyncStatus/api.test.ts | 5 +- .../(server)/ee/permissionSyncStatus/api.ts | 2 + .../linkedAccountProviderCard.test.tsx | 12 ++++- .../components/linkedAccountProviderCard.tsx | 3 +- 7 files changed, 66 insertions(+), 39 deletions(-) diff --git a/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts b/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts index 8c738c078..2cc92230d 100644 --- a/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts +++ b/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts @@ -110,6 +110,7 @@ describe('resolveActiveBanner', () => { providerType: 'bitbucket-server', reason: 'REAUTHENTICATION_REQUIRED', occurredAt: NOW.toISOString(), + isSyncing: false, }], })); expect(result?.id).toBe('permissionSync'); diff --git a/packages/web/src/app/(app)/components/banners/permissionSyncBanner.test.tsx b/packages/web/src/app/(app)/components/banners/permissionSyncBanner.test.tsx index ba348b600..89bc173cf 100644 --- a/packages/web/src/app/(app)/components/banners/permissionSyncBanner.test.tsx +++ b/packages/web/src/app/(app)/components/banners/permissionSyncBanner.test.tsx @@ -56,7 +56,7 @@ afterEach(() => { }); describe('PermissionSyncBanner', () => { - test('explains that repository access was disabled when reauthentication is required', () => { + test('prompts the user to review linked accounts when reauthentication is required', () => { renderBanner({ hasPendingFirstSync: false, issues: [{ @@ -65,16 +65,17 @@ describe('PermissionSyncBanner', () => { providerType: 'bitbucket-server', reason: 'REAUTHENTICATION_REQUIRED', occurredAt: '2026-07-22T12:00:00.000Z', + isSyncing: false, }], }); expect(screen.getByText('Repository access from Bitbucket Server needs attention.')).toBeTruthy(); - expect(screen.getByText(/Private repository access has been disabled until you reconnect/)).toBeTruthy(); + expect(screen.getByText(/Review your linked accounts to restore access to private repositories/)).toBeTruthy(); expect(screen.getByRole('link', { name: 'Review linked accounts' }).getAttribute('href')) .toBe('/settings/linked-accounts'); }); - test('provides scope-specific remediation', () => { + test('uses the same remediation when additional scope is required', () => { renderBanner({ hasPendingFirstSync: false, issues: [{ @@ -83,11 +84,11 @@ describe('PermissionSyncBanner', () => { providerType: 'github', reason: 'INSUFFICIENT_SCOPE', occurredAt: null, + isSyncing: false, }], }); - expect(screen.getByText(/required OAuth scope was not granted/)).toBeTruthy(); - expect(screen.getByText(/until you reauthorize/)).toBeTruthy(); + expect(screen.getByText(/Review your linked accounts to restore access to private repositories/)).toBeTruthy(); }); test('retains the existing initial-sync progress state', () => { @@ -95,4 +96,21 @@ describe('PermissionSyncBanner', () => { expect(screen.getByText(/Syncing repository access with Sourcebot/)).toBeTruthy(); }); + + test('shows syncing while recovering an account with an unresolved issue', () => { + renderBanner({ + hasPendingFirstSync: false, + issues: [{ + accountId: 'account_1', + providerId: 'bitbucket-server-corp', + providerType: 'bitbucket-server', + reason: 'REAUTHENTICATION_REQUIRED', + occurredAt: '2026-07-22T12:00:00.000Z', + isSyncing: true, + }], + }); + + expect(screen.getByText(/Syncing repository access with Sourcebot/)).toBeTruthy(); + expect(screen.queryByText(/needs attention/)).toBeNull(); + }); }); diff --git a/packages/web/src/app/(app)/components/banners/permissionSyncBanner.tsx b/packages/web/src/app/(app)/components/banners/permissionSyncBanner.tsx index f16a2c476..81466a4ca 100644 --- a/packages/web/src/app/(app)/components/banners/permissionSyncBanner.tsx +++ b/packages/web/src/app/(app)/components/banners/permissionSyncBanner.tsx @@ -14,7 +14,6 @@ import { Button } from "@/components/ui/button"; import Link from "next/link"; const POLL_INTERVAL_MS = 5000; -const ISSUE_POLL_INTERVAL_MS = 30000; interface PermissionSyncBannerProps extends BannerProps { initialStatus: PermissionSyncStatusResponse; @@ -27,10 +26,10 @@ export function PermissionSyncBanner({ id, dismissible, initialStatus }: Permiss queryKey: ["permissionSyncStatus"], queryFn: () => unwrapServiceError(getPermissionSyncStatus()), refetchInterval: (query) => { - if (query.state.data?.hasPendingFirstSync) { - return POLL_INTERVAL_MS; - } - return query.state.data?.issues.length ? ISSUE_POLL_INTERVAL_MS : false; + const status = query.state.data; + return status?.hasPendingFirstSync || status?.issues.length + ? POLL_INTERVAL_MS + : false; }, initialData: initialStatus, }); @@ -52,17 +51,29 @@ export function PermissionSyncBanner({ id, dismissible, initialStatus }: Permiss return null; } + const hasActiveRecoverySync = status.issues.some(issue => issue.isSyncing); + if (status.hasPendingFirstSync || hasActiveRecoverySync) { + return ( + } + title={ + + Syncing repository access with Sourcebot. + + + } + description="Sourcebot is syncing what repositories you have access to from a code host. This may take a minute." + /> + ); + } + const issue = status.issues[0]; if (issue) { const providerName = status.issues.length === 1 ? getAuthProviderInfo(issue.providerType).displayName : `${status.issues.length} code hosts`; - const requiresAdditionalScope = status.issues.some(({ reason }) => reason === 'INSUFFICIENT_SCOPE'); - const description = status.issues.length > 1 - ? "Sourcebot could not verify permissions for multiple linked accounts. Private repository access has been disabled until you review and reauthorize the affected accounts." - : requiresAdditionalScope - ? "Sourcebot could not verify your repository permissions because the required OAuth scope was not granted. Private repository access has been disabled until you reauthorize the affected account." - : "Sourcebot could not verify your repository permissions. Private repository access has been disabled until you reconnect the affected account."; return ( } title={`Repository access from ${providerName} needs attention.`} - description={description} + description="Sourcebot could not verify your repository permissions. Review your linked accounts to restore access to private repositories." action={(