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 && (