Skip to content

Commit fb358d8

Browse files
feat(web): Add banner to notify user when permissions are syncing for the first time (#852)
1 parent 19164fe commit fb358d8

File tree

7 files changed

+253
-8
lines changed

7 files changed

+253
-8
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- [EE] Added notification banner when an account's permissions are being synced for the first time. [#852](https://github.com/sourcebot-dev/sourcebot/pull/852)
12+
1013
### Fixed
1114
- Fixed issue where the branch filter in the repos detail page would not return any results. [#851](https://github.com/sourcebot-dev/sourcebot/pull/851)
1215

packages/shared/src/entitlements.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,40 @@ const entitlements = [
4646
export type Entitlement = (typeof entitlements)[number];
4747

4848
const entitlementsByPlan: Record<Plan, Entitlement[]> = {
49-
oss: ["anonymous-access"],
50-
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
51-
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics", "permission-syncing", "github-app"],
52-
"self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics", "permission-syncing", "github-app"],
49+
oss: [
50+
"anonymous-access"
51+
],
52+
"cloud:team": [
53+
"billing",
54+
"multi-tenancy",
55+
"sso",
56+
"code-nav"
57+
],
58+
"self-hosted:enterprise": [
59+
"search-contexts",
60+
"sso",
61+
"code-nav",
62+
"audit",
63+
"analytics",
64+
"permission-syncing",
65+
"github-app"
66+
],
67+
"self-hosted:enterprise-unlimited": [
68+
"search-contexts",
69+
"sso",
70+
"code-nav",
71+
"audit",
72+
"analytics",
73+
"permission-syncing",
74+
"github-app",
75+
"anonymous-access"
76+
],
5377
// Special entitlement for https://demo.sourcebot.dev
54-
"cloud:demo": ["anonymous-access", "code-nav", "search-contexts"],
78+
"cloud:demo": [
79+
"anonymous-access",
80+
"code-nav",
81+
"search-contexts"
82+
],
5583
} as const;
5684

5785

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
'use client';
2+
3+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
4+
import { Loader2, Info } from "lucide-react";
5+
import { useQuery } from "@tanstack/react-query";
6+
import { unwrapServiceError } from "@/lib/utils";
7+
import { getPermissionSyncStatus } from "@/app/api/(client)/client";
8+
import { useRouter } from "next/navigation";
9+
import { useEffect } from "react";
10+
import { usePrevious } from "@uidotdev/usehooks";
11+
12+
const POLL_INTERVAL_MS = 5000;
13+
14+
export function PermissionSyncBanner() {
15+
const router = useRouter();
16+
17+
const { data: hasPendingFirstSync, isError, isPending } = useQuery({
18+
queryKey: ["permissionSyncStatus"],
19+
queryFn: () => unwrapServiceError(getPermissionSyncStatus()),
20+
select: (data) => {
21+
return data.hasPendingFirstSync;
22+
},
23+
refetchInterval: (query) => {
24+
const hasPendingFirstSync = query.state.data?.hasPendingFirstSync;
25+
// Keep polling while sync is in progress, stop when done
26+
return hasPendingFirstSync ? POLL_INTERVAL_MS : false;
27+
},
28+
});
29+
30+
const previousHasPendingFirstSync = usePrevious(hasPendingFirstSync);
31+
32+
// Refresh the page when sync completes
33+
useEffect(() => {
34+
if (previousHasPendingFirstSync === true && hasPendingFirstSync === false) {
35+
router.refresh();
36+
}
37+
}, [hasPendingFirstSync, previousHasPendingFirstSync, router]);
38+
39+
// Don't show anything if we can't get status or no pending first sync
40+
if (isError || isPending) {
41+
return null;
42+
}
43+
44+
if (!hasPendingFirstSync) {
45+
return null;
46+
}
47+
48+
return (
49+
<Alert className="rounded-none border-x-0 border-t-0 bg-accent">
50+
<Info className="h-4 w-4 mt-0.5" />
51+
<AlertTitle className="flex items-center gap-2">
52+
Syncing repository access with Sourcebot.
53+
<Loader2 className="h-4 w-4 animate-spin" />
54+
</AlertTitle>
55+
<AlertDescription>
56+
Sourcebot is syncing what repositories you have access to from a code host. This may take a minute.
57+
</AlertDescription>
58+
</Alert>
59+
);
60+
}

packages/web/src/app/[domain]/layout.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { GitHubStarToast } from "./components/githubStarToast";
2525
import { UpgradeToast } from "./components/upgradeToast";
2626
import { getLinkedAccountProviderStates } from "@/ee/features/permissionSyncing/actions";
2727
import { LinkAccounts } from "@/ee/features/permissionSyncing/components/linkAccounts";
28+
import { PermissionSyncBanner } from "./components/permissionSyncBanner";
2829

2930
interface LayoutProps {
3031
children: React.ReactNode,
@@ -75,7 +76,7 @@ export default async function Layout(props: LayoutProps) {
7576
user: true
7677
}
7778
});
78-
79+
7980
// There's two reasons why a user might not be a member of an org:
8081
// 1. The org doesn't require member approval, but the org was at max capacity when the user registered. In this case, we show them
8182
// the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats.
@@ -96,7 +97,7 @@ export default async function Layout(props: LayoutProps) {
9697
requestedById: session.user.id
9798
}
9899
});
99-
100+
100101
if (hasPendingApproval) {
101102
return <PendingApprovalCard />
102103
} else {
@@ -154,7 +155,7 @@ export default async function Layout(props: LayoutProps) {
154155
return (
155156
<div className="min-h-screen flex items-center justify-center p-6">
156157
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
157-
<LinkAccounts linkedAccountProviderStates={linkedAccountProviderStates} callbackUrl={`/${domain}`}/>
158+
<LinkAccounts linkedAccountProviderStates={linkedAccountProviderStates} callbackUrl={`/${domain}`} />
158159
</div>
159160
)
160161
}
@@ -188,8 +189,15 @@ export default async function Layout(props: LayoutProps) {
188189
<MobileUnsupportedSplashScreen />
189190
)
190191
}
192+
const isPermissionSyncBannerVisible = session && hasEntitlement("permission-syncing");
193+
191194
return (
192195
<SyntaxGuideProvider>
196+
{
197+
isPermissionSyncBannerVisible ? (
198+
<PermissionSyncBanner />
199+
) : null
200+
}
193201
{children}
194202
<SyntaxReferenceGuide />
195203
<GitHubStarToast />

packages/web/src/app/api/(client)/client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
FileSourceRequest,
2020
FileSourceResponse,
2121
} from "@/features/git";
22+
import { PermissionSyncStatusResponse } from "../(server)/ee/permissionSyncStatus/route";
2223

2324
export const search = async (body: SearchRequest): Promise<SearchResponse | ServiceError> => {
2425
const result = await fetch("/api/search", {
@@ -124,3 +125,13 @@ export const getFiles = async (body: GetFilesRequest): Promise<GetFilesResponse
124125
}).then(response => response.json());
125126
return result as GetFilesResponse | ServiceError;
126127
}
128+
129+
export const getPermissionSyncStatus = async (): Promise<PermissionSyncStatusResponse | ServiceError> => {
130+
const result = await fetch("/api/ee/permissionSyncStatus", {
131+
method: "GET",
132+
headers: {
133+
"X-Sourcebot-Client-Source": "sourcebot-web-client",
134+
},
135+
}).then(response => response.json());
136+
return result as PermissionSyncStatusResponse | ServiceError;
137+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use server';
2+
3+
import { apiHandler } from "@/lib/apiHandler";
4+
import { serviceErrorResponse } from "@/lib/serviceError";
5+
import { isServiceError } from "@/lib/utils";
6+
import { withAuthV2 } from "@/withAuthV2";
7+
import { getEntitlements } from "@sourcebot/shared";
8+
import { AccountPermissionSyncJobStatus } from "@sourcebot/db";
9+
import { StatusCodes } from "http-status-codes";
10+
import { ErrorCode } from "@/lib/errorCodes";
11+
12+
export interface PermissionSyncStatusResponse {
13+
hasPendingFirstSync: boolean;
14+
}
15+
16+
/**
17+
* Returns whether a user has a account that has it's permissions
18+
* synced for the first time.
19+
*/
20+
export const GET = apiHandler(async () => {
21+
const entitlements = getEntitlements();
22+
if (!entitlements.includes('permission-syncing')) {
23+
return serviceErrorResponse({
24+
statusCode: StatusCodes.FORBIDDEN,
25+
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
26+
message: "Permission syncing is not enabled for your license",
27+
});
28+
}
29+
30+
const result = await withAuthV2(async ({ prisma, user }) => {
31+
const accounts = await prisma.account.findMany({
32+
where: {
33+
userId: user.id,
34+
provider: { in: ['github', 'gitlab'] }
35+
},
36+
include: {
37+
permissionSyncJobs: {
38+
orderBy: { createdAt: 'desc' },
39+
take: 1,
40+
}
41+
}
42+
});
43+
44+
const activeStatuses: AccountPermissionSyncJobStatus[] = [
45+
AccountPermissionSyncJobStatus.PENDING,
46+
AccountPermissionSyncJobStatus.IN_PROGRESS
47+
];
48+
49+
const hasPendingFirstSync = accounts.some(account =>
50+
account.permissionSyncedAt === null &&
51+
account.permissionSyncJobs.length > 0 &&
52+
activeStatuses.includes(account.permissionSyncJobs[0].status)
53+
);
54+
55+
return { hasPendingFirstSync } satisfies PermissionSyncStatusResponse;
56+
});
57+
58+
if (isServiceError(result)) {
59+
return serviceErrorResponse(result);
60+
}
61+
62+
return Response.json(result, { status: StatusCodes.OK });
63+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as React from "react"
2+
import { cva, type VariantProps } from "class-variance-authority"
3+
4+
import { cn } from "@/lib/utils"
5+
6+
const alertVariants = cva("grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 w-full relative group/alert", {
7+
variants: {
8+
variant: {
9+
default: "bg-card text-card-foreground",
10+
destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
11+
},
12+
},
13+
defaultVariants: {
14+
variant: "default",
15+
},
16+
})
17+
18+
function Alert({
19+
className,
20+
variant,
21+
...props
22+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
23+
return (
24+
<div
25+
data-slot="alert"
26+
role="alert"
27+
className={cn(alertVariants({ variant }), className)}
28+
{...props}
29+
/>
30+
)
31+
}
32+
33+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
34+
return (
35+
<div
36+
data-slot="alert-title"
37+
className={cn(
38+
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
39+
className
40+
)}
41+
{...props}
42+
/>
43+
)
44+
}
45+
46+
function AlertDescription({
47+
className,
48+
...props
49+
}: React.ComponentProps<"div">) {
50+
return (
51+
<div
52+
data-slot="alert-description"
53+
className={cn(
54+
"text-muted-foreground text-sm text-balance md:text-pretty group-has-[>svg]/alert:col-start-2 [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
55+
className
56+
)}
57+
{...props}
58+
/>
59+
)
60+
}
61+
62+
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
63+
return (
64+
<div
65+
data-slot="alert-action"
66+
className={cn("absolute top-2 right-2", className)}
67+
{...props}
68+
/>
69+
)
70+
}
71+
72+
export { Alert, AlertTitle, AlertDescription, AlertAction }

0 commit comments

Comments
 (0)