diff --git a/docs/snippets/schemas/v3/identityProvider.schema.mdx b/docs/snippets/schemas/v3/identityProvider.schema.mdx
index f96ad135..c0cbab9e 100644
--- a/docs/snippets/schemas/v3/identityProvider.schema.mdx
+++ b/docs/snippets/schemas/v3/identityProvider.schema.mdx
@@ -229,8 +229,7 @@
"provider",
"purpose",
"clientId",
- "clientSecret",
- "baseUrl"
+ "clientSecret"
]
},
"GoogleIdentityProviderConfig": {
@@ -887,8 +886,7 @@
"provider",
"purpose",
"clientId",
- "clientSecret",
- "baseUrl"
+ "clientSecret"
]
},
{
diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx
index 06f02825..0bc6281b 100644
--- a/docs/snippets/schemas/v3/index.schema.mdx
+++ b/docs/snippets/schemas/v3/index.schema.mdx
@@ -4633,8 +4633,7 @@
"provider",
"purpose",
"clientId",
- "clientSecret",
- "baseUrl"
+ "clientSecret"
]
},
"GoogleIdentityProviderConfig": {
@@ -5291,8 +5290,7 @@
"provider",
"purpose",
"clientId",
- "clientSecret",
- "baseUrl"
+ "clientSecret"
]
},
{
diff --git a/packages/schemas/src/v3/identityProvider.schema.ts b/packages/schemas/src/v3/identityProvider.schema.ts
index 10cd4487..9a3b9373 100644
--- a/packages/schemas/src/v3/identityProvider.schema.ts
+++ b/packages/schemas/src/v3/identityProvider.schema.ts
@@ -228,8 +228,7 @@ const schema = {
"provider",
"purpose",
"clientId",
- "clientSecret",
- "baseUrl"
+ "clientSecret"
]
},
"GoogleIdentityProviderConfig": {
@@ -886,8 +885,7 @@ const schema = {
"provider",
"purpose",
"clientId",
- "clientSecret",
- "baseUrl"
+ "clientSecret"
]
},
{
diff --git a/packages/schemas/src/v3/identityProvider.type.ts b/packages/schemas/src/v3/identityProvider.type.ts
index 2a180b4f..541f0ca7 100644
--- a/packages/schemas/src/v3/identityProvider.type.ts
+++ b/packages/schemas/src/v3/identityProvider.type.ts
@@ -83,7 +83,7 @@ export interface GitLabIdentityProviderConfig {
*/
googleCloudSecret: string;
};
- baseUrl:
+ baseUrl?:
| {
/**
* The name of the environment variable that contains the token.
diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts
index 1d58a4a6..4da1b5e9 100644
--- a/packages/schemas/src/v3/index.schema.ts
+++ b/packages/schemas/src/v3/index.schema.ts
@@ -4632,8 +4632,7 @@ const schema = {
"provider",
"purpose",
"clientId",
- "clientSecret",
- "baseUrl"
+ "clientSecret"
]
},
"GoogleIdentityProviderConfig": {
@@ -5290,8 +5289,7 @@ const schema = {
"provider",
"purpose",
"clientId",
- "clientSecret",
- "baseUrl"
+ "clientSecret"
]
},
{
diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts
index 9c9365cb..aef3e6ac 100644
--- a/packages/schemas/src/v3/index.type.ts
+++ b/packages/schemas/src/v3/index.type.ts
@@ -1190,7 +1190,7 @@ export interface GitLabIdentityProviderConfig {
*/
googleCloudSecret: string;
};
- baseUrl:
+ baseUrl?:
| {
/**
* The name of the environment variable that contains the token.
diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx
index 05f91006..bfc530c1 100644
--- a/packages/web/src/app/[domain]/layout.tsx
+++ b/packages/web/src/app/[domain]/layout.tsx
@@ -23,7 +23,7 @@ import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { GitHubStarToast } from "./components/githubStarToast";
import { UpgradeToast } from "./components/upgradeToast";
-import { getUnlinkedIntegrationProviders, userNeedsToLinkIdentityProvider } from "@/ee/features/permissionSyncing/actions";
+import { getIntegrationProviderStates } from "@/ee/features/permissionSyncing/actions";
import { LinkAccounts } from "@/ee/features/permissionSyncing/linkAccounts";
interface LayoutProps {
@@ -126,16 +126,16 @@ export default async function Layout(props: LayoutProps) {
}
if (hasEntitlement("permission-syncing")) {
- const unlinkedAccounts = await getUnlinkedIntegrationProviders();
- if (isServiceError(unlinkedAccounts)) {
+ const integrationProviderStates = await getIntegrationProviderStates();
+ if (isServiceError(integrationProviderStates)) {
return (
An error occurred
- {typeof unlinkedAccounts.message === 'string'
- ? unlinkedAccounts.message
+ {typeof integrationProviderStates.message === 'string'
+ ? integrationProviderStates.message
: "A server error occurred while checking your account status. Please try again or contact support."}
@@ -143,25 +143,18 @@ export default async function Layout(props: LayoutProps) {
)
}
- if (unlinkedAccounts.length > 0) {
- // Separate required and optional providers
- const requiredProviders = unlinkedAccounts.filter(p => p.required !== false);
- const hasRequiredProviders = requiredProviders.length > 0;
-
- // Check if user has skipped optional providers
+ const hasUnlinkedProviders = integrationProviderStates.some(state => state.isLinked === false);
+ if (hasUnlinkedProviders) {
const cookieStore = await cookies();
const hasSkippedOptional = cookieStore.has(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME);
- // Show LinkAccounts if:
- // 1. There are required providers, OR
- // 2. There are only optional providers AND user hasn't skipped yet
- const shouldShowLinkAccounts = hasRequiredProviders || !hasSkippedOptional;
-
+ const hasUnlinkedRequiredProviders = integrationProviderStates.some(state => state.required && !state.isLinked)
+ const shouldShowLinkAccounts = hasUnlinkedRequiredProviders || !hasSkippedOptional;
if (shouldShowLinkAccounts) {
return (
-
+
)
}
diff --git a/packages/web/src/app/[domain]/settings/permission-syncing/linkButton.tsx b/packages/web/src/app/[domain]/settings/permission-syncing/linkButton.tsx
deleted file mode 100644
index 49750a3a..00000000
--- a/packages/web/src/app/[domain]/settings/permission-syncing/linkButton.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-'use client';
-
-import { Button } from "@/components/ui/button";
-import { Link2 } from "lucide-react";
-import { signIn } from "next-auth/react";
-
-interface LinkButtonProps {
- provider: string;
- providerName: string;
- callbackUrl: string;
-}
-
-export const LinkButton = ({ provider, providerName, callbackUrl }: LinkButtonProps) => {
- const handleLink = () => {
- signIn(provider, {
- redirectTo: callbackUrl
- });
- };
-
- return (
-
- );
-};
diff --git a/packages/web/src/app/[domain]/settings/permission-syncing/page.tsx b/packages/web/src/app/[domain]/settings/permission-syncing/page.tsx
index a3cc04f2..54f6c663 100644
--- a/packages/web/src/app/[domain]/settings/permission-syncing/page.tsx
+++ b/packages/web/src/app/[domain]/settings/permission-syncing/page.tsx
@@ -2,19 +2,11 @@ import { hasEntitlement } from "@sourcebot/shared";
import { notFound } from "@/lib/serviceError";
import { LinkedAccountsSettings } from "@/ee/features/permissionSyncing/linkedAccountsSettings";
-interface PermissionSyncingPageProps {
- params: Promise<{
- domain: string;
- }>
-}
-
-export default async function PermissionSyncingPage(props: PermissionSyncingPageProps) {
- const params = await props.params;
-
+export default async function PermissionSyncingPage() {
const hasPermissionSyncingEntitlement = await hasEntitlement("permission-syncing");
if (!hasPermissionSyncingEntitlement) {
notFound();
}
- return
;
+ return
;
}
diff --git a/packages/web/src/ee/features/permissionSyncing/actions.ts b/packages/web/src/ee/features/permissionSyncing/actions.ts
index 29762de1..ded04271 100644
--- a/packages/web/src/ee/features/permissionSyncing/actions.ts
+++ b/packages/web/src/ee/features/permissionSyncing/actions.ts
@@ -8,76 +8,51 @@ import { env } from "@/env.mjs";
import { OrgRole } from "@sourcebot/db";
import { cookies } from "next/headers";
import { OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants";
+import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types";
const logger = createLogger('web-ee-permission-syncing-actions');
-export const userNeedsToLinkIdentityProvider = async () => sew(() =>
+export const getIntegrationProviderStates = async () => sew(() =>
withAuthV2(async ({ prisma, role, user }) =>
withMinimumOrgRole(role, OrgRole.MEMBER, async () => {
const config = await loadConfig(env.CONFIG_PATH);
- const identityProviders = config.identityProviders ?? [];
-
- for (const identityProvider of identityProviders) {
- if (identityProvider.purpose === "integration") {
- // Only check required providers (default to true if not specified)
- const isRequired = 'required' in identityProvider ? identityProvider.required : true;
-
- if (!isRequired) {
- continue;
+ const integrationProviderConfigs = config.identityProviders ?? [];
+ const linkedAccounts = await prisma.account.findMany({
+ where: {
+ userId: user.id,
+ provider: {
+ in: integrationProviderConfigs.map(p => p.provider)
}
+ },
+ select: {
+ provider: true,
+ providerAccountId: true
+ }
+ });
- const linkedAccount = await prisma.account.findFirst({
- where: {
- provider: identityProvider.provider,
- userId: user.id,
- },
- });
+ const integrationProviderState: IntegrationIdentityProviderState[] = [];
+ for (const integrationProviderConfig of integrationProviderConfigs) {
+ if (integrationProviderConfig.purpose === "integration") {
+ const linkedAccount = linkedAccounts.find(
+ account => account.provider === integrationProviderConfig.provider
+ );
- if (!linkedAccount) {
- logger.info(`Required integration identity provider ${identityProvider.provider} account info not found for user ${user.id}`);
- return true;
- }
+ const isLinked = !!linkedAccount;
+ const isRequired = integrationProviderConfig.required ?? true;
+ integrationProviderState.push({
+ id: integrationProviderConfig.provider,
+ required: isRequired,
+ isLinked,
+ linkedAccountId: linkedAccount?.providerAccountId
+ } as IntegrationIdentityProviderState);
}
}
- return false;
+ return integrationProviderState;
})
)
);
-export const getUnlinkedIntegrationProviders = async () => sew(() =>
- withAuthV2(async ({ prisma, role, user }) =>
- withMinimumOrgRole(role, OrgRole.MEMBER, async () => {
- const config = await loadConfig(env.CONFIG_PATH);
- const identityProviders = config.identityProviders ?? [];
- const unlinkedProviders = [];
-
- for (const identityProvider of identityProviders) {
- if (identityProvider.purpose === "integration") {
- const linkedAccount = await prisma.account.findFirst({
- where: {
- provider: identityProvider.provider,
- userId: user.id,
- },
- });
-
- if (!linkedAccount) {
- const isRequired = 'required' in identityProvider ? identityProvider.required as boolean : true;
- logger.info(`Integration identity provider ${identityProvider.provider} not linked for user ${user.id}`);
- unlinkedProviders.push({
- id: identityProvider.provider,
- name: identityProvider.provider,
- purpose: "integration" as const,
- required: isRequired,
- });
- }
- }
- }
-
- return unlinkedProviders;
- })
- )
-);
export const unlinkIntegrationProvider = async (provider: string) => sew(() =>
withAuthV2(async ({ prisma, role, user }) =>
@@ -85,12 +60,8 @@ export const unlinkIntegrationProvider = async (provider: string) => sew(() =>
const config = await loadConfig(env.CONFIG_PATH);
const identityProviders = config.identityProviders ?? [];
- // Verify this is an integration provider
- const isIntegrationProvider = identityProviders.some(
- idp => idp.provider === provider && idp.purpose === "integration"
- );
-
- if (!isIntegrationProvider) {
+ const providerConfig = identityProviders.find(idp => idp.provider === provider)
+ if (!providerConfig || !('purpose' in providerConfig) || providerConfig.purpose !== "integration") {
throw new Error("Provider is not an integration provider");
}
@@ -104,6 +75,14 @@ export const unlinkIntegrationProvider = async (provider: string) => sew(() =>
logger.info(`Unlinked integration provider ${provider} for user ${user.id}. Deleted ${result.count} account(s).`);
+ // If we're unlinking a required identity provider then we want to wipe the optional skip cookie if it exists so that we give the
+ // user the option of linking optional providers in the same link accounts screen
+ const isRequired = providerConfig.required ?? true;
+ if (isRequired) {
+ const cookieStore = await cookies();
+ cookieStore.delete(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME);
+ }
+
return { success: true, count: result.count };
})
)
diff --git a/packages/web/src/ee/features/permissionSyncing/components/providerInfo.tsx b/packages/web/src/ee/features/permissionSyncing/components/providerInfo.tsx
index 14307506..3d1d8339 100644
--- a/packages/web/src/ee/features/permissionSyncing/components/providerInfo.tsx
+++ b/packages/web/src/ee/features/permissionSyncing/components/providerInfo.tsx
@@ -1,5 +1,4 @@
import { getAuthProviderInfo } from "@/lib/utils";
-import { ProviderIcon } from "./providerIcon";
import { ProviderBadge } from "./providerBadge";
interface ProviderInfoProps {
diff --git a/packages/web/src/ee/features/permissionSyncing/integrationProviderCard.tsx b/packages/web/src/ee/features/permissionSyncing/integrationProviderCard.tsx
new file mode 100644
index 00000000..43e948b1
--- /dev/null
+++ b/packages/web/src/ee/features/permissionSyncing/integrationProviderCard.tsx
@@ -0,0 +1,90 @@
+import { getAuthProviderInfo } from "@/lib/utils";
+import { Check, X } from "lucide-react";
+import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { ProviderIcon } from "./components/providerIcon";
+import { ProviderInfo } from "./components/providerInfo";
+import { UnlinkButton } from "./unlinkButton";
+import { LinkButton } from "./linkButton";
+import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types"
+import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
+
+interface IntegrationProviderCardProps {
+ integrationProviderState: IntegrationIdentityProviderState;
+ callbackUrl?: string;
+}
+
+export function IntegrationProviderCard({
+ integrationProviderState,
+ callbackUrl,
+}: IntegrationProviderCardProps) {
+ const providerInfo = getAuthProviderInfo(integrationProviderState.id);
+ const defaultCallbackUrl = `/${SINGLE_TENANT_ORG_DOMAIN}/settings/permission-syncing`;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {integrationProviderState.isLinked? (
+
+
+
+
+ Connected
+
+
+ {integrationProviderState.linkedAccountId && (
+ <>
+
•
+
+ {integrationProviderState.linkedAccountId}
+
+ >
+ )}
+
+ ) : (
+
+
+
+ Not connected
+
+
+ )}
+
+
+
+
+ {integrationProviderState.isLinked? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
diff --git a/packages/web/src/ee/features/permissionSyncing/linkAccounts.tsx b/packages/web/src/ee/features/permissionSyncing/linkAccounts.tsx
index cad1d435..52859269 100644
--- a/packages/web/src/ee/features/permissionSyncing/linkAccounts.tsx
+++ b/packages/web/src/ee/features/permissionSyncing/linkAccounts.tsx
@@ -1,108 +1,58 @@
'use client';
-import { signIn } from "next-auth/react";
-import { getAuthProviderInfo } from "@/lib/utils";
-import type { IdentityProviderMetadata } from "@/lib/identityProviders";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
-import { ArrowRight } from "lucide-react";
-import { skipOptionalProvidersLink } from "./actions";
+import { skipOptionalProvidersLink } from "@/ee/features/permissionSyncing/actions";
import { useRouter } from "next/navigation";
import { useState } from "react";
-import { ProviderIcon } from "./components/providerIcon";
-import { ProviderInfo } from "./components/providerInfo";
+import { IntegrationProviderCard } from "./integrationProviderCard";
+import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types";
interface LinkAccountsProps {
- unlinkedAccounts: IdentityProviderMetadata[];
+ integrationProviderStates: IntegrationIdentityProviderState[]
callbackUrl?: string;
}
-export const LinkAccounts = ({ unlinkedAccounts, callbackUrl }: LinkAccountsProps) => {
+export const LinkAccounts = ({ integrationProviderStates, callbackUrl }: LinkAccountsProps) => {
const router = useRouter();
const [isSkipping, setIsSkipping] = useState(false);
- const handleSignIn = (providerId: string) => {
- signIn(providerId, {
- redirectTo: callbackUrl ?? "/"
- });
- };
-
const handleSkip = async () => {
setIsSkipping(true);
try {
await skipOptionalProvidersLink();
- router.refresh();
} catch (error) {
console.error("Failed to skip optional providers:", error);
+ } finally {
setIsSkipping(false);
+ router.refresh()
}
};
- // Separate required and optional providers
- const requiredProviders = unlinkedAccounts.filter(p => p.required !== false);
- const optionalProviders = unlinkedAccounts.filter(p => p.required === false);
- const hasOnlyOptionalProviders = requiredProviders.length === 0 && optionalProviders.length > 0;
-
- const renderProviderButton = (provider: IdentityProviderMetadata) => {
- const providerInfo = getAuthProviderInfo(provider.id);
- const isRequired = provider.required !== false;
-
- return (
-
- );
- };
-
+ const canSkip = !integrationProviderStates.some(state => state.required && !state.isLinked);
return (
Connect Your Accounts
- {hasOnlyOptionalProviders ? (
- <>
- The following optional accounts can be linked to enhance your experience.
-
- You can link them now or skip and manage them later in Settings → Linked Accounts.
- >
- ) : (
- <>
- Link the following accounts to enable permission syncing and access all features.
-
- You can manage your linked accounts later in Settings → Linked Accounts.
- >
- )}
+ Link the following accounts to enable permission syncing and access all features.
+
+ You can manage your linked accounts later in Settings → Linked Accounts.
- {requiredProviders.map(renderProviderButton)}
- {optionalProviders.map(renderProviderButton)}
+ {integrationProviderStates
+ .sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
+ .map(state => (
+
+ ))}
- {hasOnlyOptionalProviders && (
+ {canSkip && (