Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [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)

### Fixed
- 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)

Expand Down
38 changes: 33 additions & 5 deletions packages/shared/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,40 @@ const entitlements = [
export type Entitlement = (typeof entitlements)[number];

const entitlementsByPlan: Record<Plan, Entitlement[]> = {
oss: ["anonymous-access"],
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics", "permission-syncing", "github-app"],
"self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics", "permission-syncing", "github-app"],
oss: [
"anonymous-access"
],
"cloud:team": [
"billing",
"multi-tenancy",
"sso",
"code-nav"
],
"self-hosted:enterprise": [
"search-contexts",
"sso",
"code-nav",
"audit",
"analytics",
"permission-syncing",
"github-app"
],
"self-hosted:enterprise-unlimited": [
"search-contexts",
"sso",
"code-nav",
"audit",
"analytics",
"permission-syncing",
"github-app",
"anonymous-access"
],
// Special entitlement for https://demo.sourcebot.dev
"cloud:demo": ["anonymous-access", "code-nav", "search-contexts"],
"cloud:demo": [
"anonymous-access",
"code-nav",
"search-contexts"
],
} as const;


Expand Down
60 changes: 60 additions & 0 deletions packages/web/src/app/[domain]/components/permissionSyncBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use client';

import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Loader2, Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { 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";

const POLL_INTERVAL_MS = 5000;

export function PermissionSyncBanner() {
const router = useRouter();

const { data: hasPendingFirstSync, isError, isPending } = useQuery({
queryKey: ["permissionSyncStatus"],
queryFn: () => unwrapServiceError(getPermissionSyncStatus()),
select: (data) => {
return data.hasPendingFirstSync;
},
refetchInterval: (query) => {
const hasPendingFirstSync = query.state.data;
// Keep polling while sync is in progress, stop when done
return hasPendingFirstSync ? POLL_INTERVAL_MS : false;
},
Comment thread
brendan-kellam marked this conversation as resolved.
});

const previousHasPendingFirstSync = usePrevious(hasPendingFirstSync);

// Refresh the page when sync completes
useEffect(() => {
if (previousHasPendingFirstSync === true && hasPendingFirstSync === false) {
router.refresh();
}
}, [hasPendingFirstSync, previousHasPendingFirstSync, router]);

// Don't show anything if we can't get status or no pending first sync
if (isError || isPending) {
return null;
}

if (!hasPendingFirstSync) {
return null;
}

return (
<Alert className="rounded-none border-x-0 border-t-0 bg-accent">
<Info className="h-4 w-4 mt-0.5" />
<AlertTitle className="flex items-center gap-2">
Syncing repository access with Sourcebot.
<Loader2 className="h-4 w-4 animate-spin" />
</AlertTitle>
<AlertDescription>
Sourcebot is syncing what repositories you have access to from a code host. This may take a minute.
</AlertDescription>
</Alert>
);
}
14 changes: 11 additions & 3 deletions packages/web/src/app/[domain]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { GitHubStarToast } from "./components/githubStarToast";
import { UpgradeToast } from "./components/upgradeToast";
import { getLinkedAccountProviderStates } from "@/ee/features/permissionSyncing/actions";
import { LinkAccounts } from "@/ee/features/permissionSyncing/components/linkAccounts";
import { PermissionSyncBanner } from "./components/permissionSyncBanner";

interface LayoutProps {
children: React.ReactNode,
Expand Down Expand Up @@ -75,7 +76,7 @@ export default async function Layout(props: LayoutProps) {
user: true
}
});

// There's two reasons why a user might not be a member of an org:
// 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
// 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.
Expand All @@ -96,7 +97,7 @@ export default async function Layout(props: LayoutProps) {
requestedById: session.user.id
}
});

if (hasPendingApproval) {
return <PendingApprovalCard />
} else {
Expand Down Expand Up @@ -154,7 +155,7 @@ export default async function Layout(props: LayoutProps) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<LinkAccounts linkedAccountProviderStates={linkedAccountProviderStates} callbackUrl={`/${domain}`}/>
<LinkAccounts linkedAccountProviderStates={linkedAccountProviderStates} callbackUrl={`/${domain}`} />
</div>
)
}
Expand Down Expand Up @@ -188,8 +189,15 @@ export default async function Layout(props: LayoutProps) {
<MobileUnsupportedSplashScreen />
)
}
const isPermissionSyncBannerVisible = session && hasEntitlement("permission-syncing");

return (
<SyntaxGuideProvider>
{
isPermissionSyncBannerVisible ? (
<PermissionSyncBanner />
) : null
}
{children}
<SyntaxReferenceGuide />
<GitHubStarToast />
Expand Down
11 changes: 11 additions & 0 deletions packages/web/src/app/api/(client)/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
FileSourceRequest,
FileSourceResponse,
} from "@/features/git";
import { PermissionSyncStatusResponse } from "../(server)/ee/permissionSyncStatus/route";

export const search = async (body: SearchRequest): Promise<SearchResponse | ServiceError> => {
const result = await fetch("/api/search", {
Expand Down Expand Up @@ -124,3 +125,13 @@ export const getFiles = async (body: GetFilesRequest): Promise<GetFilesResponse
}).then(response => response.json());
return result as GetFilesResponse | ServiceError;
}

export const getPermissionSyncStatus = async (): Promise<PermissionSyncStatusResponse | ServiceError> => {
const result = await fetch("/api/ee/permissionSyncStatus", {
method: "GET",
headers: {
"X-Sourcebot-Client-Source": "sourcebot-web-client",
},
}).then(response => response.json());
return result as PermissionSyncStatusResponse | ServiceError;
}
63 changes: 63 additions & 0 deletions packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use server';

import { apiHandler } from "@/lib/apiHandler";
import { serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { withAuthV2 } from "@/withAuthV2";
import { getEntitlements } from "@sourcebot/shared";
import { AccountPermissionSyncJobStatus } from "@sourcebot/db";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";

export interface PermissionSyncStatusResponse {
hasPendingFirstSync: boolean;
}

/**
* Returns whether a user has a account that has it's permissions
* synced for the first time.
Comment thread
brendan-kellam marked this conversation as resolved.
*/
export const GET = apiHandler(async () => {
const entitlements = getEntitlements();
if (!entitlements.includes('permission-syncing')) {
return serviceErrorResponse({
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.NOT_FOUND,
message: "Permission syncing is not enabled for your license",
});
Comment thread
brendan-kellam marked this conversation as resolved.
}

const result = await withAuthV2(async ({ prisma, user }) => {
const accounts = await prisma.account.findMany({
where: {
userId: user.id,
provider: { in: ['github', 'gitlab'] }
},
include: {
permissionSyncJobs: {
orderBy: { createdAt: 'desc' },
take: 1,
}
}
});

const activeStatuses: AccountPermissionSyncJobStatus[] = [
AccountPermissionSyncJobStatus.PENDING,
AccountPermissionSyncJobStatus.IN_PROGRESS
];

const hasPendingFirstSync = accounts.some(account =>
account.permissionSyncedAt === null &&
account.permissionSyncJobs.length > 0 &&
activeStatuses.includes(account.permissionSyncJobs[0].status)
);

return { hasPendingFirstSync } satisfies PermissionSyncStatusResponse;
});

if (isServiceError(result)) {
return serviceErrorResponse(result);
}

return Response.json(result, { status: StatusCodes.OK });
});
72 changes: 72 additions & 0 deletions packages/web/src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

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", {
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
})

function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}

function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
className
)}
{...props}
/>
)
}

function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"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",
className
)}
{...props}
/>
)
}

function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}

export { Alert, AlertTitle, AlertDescription, AlertAction }