From b1259c564fae19d95c2c1858b6e5ec083650f5d4 Mon Sep 17 00:00:00 2001 From: msukkari Date: Sun, 2 Nov 2025 16:11:48 -0800 Subject: [PATCH] add logic for account link onboarding --- .../schemas/v3/identityProvider.schema.mdx | 16 ++ docs/snippets/schemas/v3/index.schema.mdx | 16 ++ .../schemas/src/v3/identityProvider.schema.ts | 16 ++ .../schemas/src/v3/identityProvider.type.ts | 2 + packages/schemas/src/v3/index.schema.ts | 16 ++ packages/schemas/src/v3/index.type.ts | 2 + packages/shared/src/utils.ts | 6 +- packages/web/src/actions.ts | 4 +- packages/web/src/app/[domain]/layout.tsx | 47 +++++- .../web/src/app/[domain]/settings/layout.tsx | 9 + .../permission-syncing/linkButton.tsx | 30 ++++ .../settings/permission-syncing/page.tsx | 20 +++ .../src/app/components/authMethodSelector.tsx | 2 +- packages/web/src/app/invite/page.tsx | 2 +- .../src/app/login/components/loginForm.tsx | 2 +- packages/web/src/app/login/page.tsx | 2 +- packages/web/src/app/onboard/page.tsx | 2 +- packages/web/src/app/signup/page.tsx | 2 +- packages/web/src/auth.ts | 1 + .../ee/features/permissionSyncing/actions.ts | 119 +++++++++++++ .../components/providerBadge.tsx | 16 ++ .../components/providerIcon.tsx | 48 ++++++ .../components/providerInfo.tsx | 24 +++ .../permissionSyncing/linkAccounts.tsx | 118 +++++++++++++ .../features/permissionSyncing/linkButton.tsx | 31 ++++ .../linkedAccountsSettings.tsx | 159 ++++++++++++++++++ .../permissionSyncing/unlinkButton.tsx | 75 +++++++++ packages/web/src/ee/features/sso/sso.ts | 27 +-- packages/web/src/features/chat/actions.ts | 17 +- packages/web/src/initialize.ts | 42 +++-- packages/web/src/lib/constants.ts | 1 + ...{authProviders.ts => identityProviders.ts} | 1 + schemas/v3/identityProvider.json | 8 + 33 files changed, 825 insertions(+), 58 deletions(-) create mode 100644 packages/web/src/app/[domain]/settings/permission-syncing/linkButton.tsx create mode 100644 packages/web/src/app/[domain]/settings/permission-syncing/page.tsx create mode 100644 packages/web/src/ee/features/permissionSyncing/actions.ts create mode 100644 packages/web/src/ee/features/permissionSyncing/components/providerBadge.tsx create mode 100644 packages/web/src/ee/features/permissionSyncing/components/providerIcon.tsx create mode 100644 packages/web/src/ee/features/permissionSyncing/components/providerInfo.tsx create mode 100644 packages/web/src/ee/features/permissionSyncing/linkAccounts.tsx create mode 100644 packages/web/src/ee/features/permissionSyncing/linkButton.tsx create mode 100644 packages/web/src/ee/features/permissionSyncing/linkedAccountsSettings.tsx create mode 100644 packages/web/src/ee/features/permissionSyncing/unlinkButton.tsx rename packages/web/src/lib/{authProviders.ts => identityProviders.ts} (96%) diff --git a/docs/snippets/schemas/v3/identityProvider.schema.mdx b/docs/snippets/schemas/v3/identityProvider.schema.mdx index 905730ef..5105f3f1 100644 --- a/docs/snippets/schemas/v3/identityProvider.schema.mdx +++ b/docs/snippets/schemas/v3/identityProvider.schema.mdx @@ -66,6 +66,10 @@ "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -137,6 +141,10 @@ "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -482,6 +490,10 @@ "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -553,6 +565,10 @@ "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx index 0a3af5ec..5d043b95 100644 --- a/docs/snippets/schemas/v3/index.schema.mdx +++ b/docs/snippets/schemas/v3/index.schema.mdx @@ -3677,6 +3677,10 @@ "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -3748,6 +3752,10 @@ "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -4093,6 +4101,10 @@ "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -4164,6 +4176,10 @@ "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ diff --git a/packages/schemas/src/v3/identityProvider.schema.ts b/packages/schemas/src/v3/identityProvider.schema.ts index 4e849a4c..1d351539 100644 --- a/packages/schemas/src/v3/identityProvider.schema.ts +++ b/packages/schemas/src/v3/identityProvider.schema.ts @@ -65,6 +65,10 @@ const schema = { "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -136,6 +140,10 @@ const schema = { "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -481,6 +489,10 @@ const schema = { "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -552,6 +564,10 @@ const schema = { "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ diff --git a/packages/schemas/src/v3/identityProvider.type.ts b/packages/schemas/src/v3/identityProvider.type.ts index 6c45243b..5cac3a74 100644 --- a/packages/schemas/src/v3/identityProvider.type.ts +++ b/packages/schemas/src/v3/identityProvider.type.ts @@ -30,6 +30,7 @@ export interface GitHubIdentityProviderConfig { */ env: string; }; + required?: boolean; [k: string]: unknown; } export interface GitLabIdentityProviderConfig { @@ -53,6 +54,7 @@ export interface GitLabIdentityProviderConfig { */ env: string; }; + required?: boolean; [k: string]: unknown; } export interface GoogleIdentityProviderConfig { diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index bf694f2c..3133915e 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -3676,6 +3676,10 @@ const schema = { "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -3747,6 +3751,10 @@ const schema = { "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -4092,6 +4100,10 @@ const schema = { "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ @@ -4163,6 +4175,10 @@ const schema = { "additionalProperties": false } ] + }, + "required": { + "type": "boolean", + "default": true } }, "required": [ diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index 708052ca..e7ab4ee4 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -981,6 +981,7 @@ export interface GitHubIdentityProviderConfig { */ env: string; }; + required?: boolean; [k: string]: unknown; } export interface GitLabIdentityProviderConfig { @@ -1004,6 +1005,7 @@ export interface GitLabIdentityProviderConfig { */ env: string; }; + required?: boolean; [k: string]: unknown; } export interface GoogleIdentityProviderConfig { diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index f7c1b56e..4eb454bd 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -81,7 +81,11 @@ export const loadJsonFile = async ( } } -export const loadConfig = async (configPath: string): Promise => { +export const loadConfig = async (configPath?: string): Promise => { + if (!configPath) { + throw new Error('CONFIG_PATH is required but not provided'); + } + const configContent = await (async () => { if (isRemotePath(configPath)) { const response = await fetch(configPath); diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 895084e0..4ebfd552 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -4,12 +4,12 @@ import { getAuditService } from "@/ee/features/audit/factory"; import { env } from "@/env.mjs"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; +import { notAuthenticated, notFound, orgNotFound, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; import { getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils"; import { prisma } from "@/prisma"; import { render } from "@react-email/components"; import * as Sentry from '@sentry/nextjs'; -import { encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto"; +import { generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto"; import { ApiKey, ConnectionSyncJobStatus, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType, StripeSubscriptionStatus } from "@sourcebot/db"; import { createLogger } from "@sourcebot/logger"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index b60a50a6..05f91006 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -7,7 +7,7 @@ import { UpgradeGuard } from "./components/upgradeGuard"; import { cookies, headers } from "next/headers"; import { getSelectorsByUserAgent } from "react-device-detect"; import { MobileUnsupportedSplashScreen } from "./components/mobileUnsupportedSplashScreen"; -import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants"; +import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants"; import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; @@ -23,6 +23,8 @@ 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 { LinkAccounts } from "@/ee/features/permissionSyncing/linkAccounts"; interface LayoutProps { children: React.ReactNode, @@ -123,6 +125,49 @@ export default async function Layout(props: LayoutProps) { ) } + if (hasEntitlement("permission-syncing")) { + const unlinkedAccounts = await getUnlinkedIntegrationProviders(); + if (isServiceError(unlinkedAccounts)) { + return ( +
+ +
+

An error occurred

+

+ {typeof unlinkedAccounts.message === 'string' + ? unlinkedAccounts.message + : "A server error occurred while checking your account status. Please try again or contact support."} +

+
+
+ ) + } + + 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 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; + + if (shouldShowLinkAccounts) { + return ( +
+ + +
+ ) + } + } + } + if (IS_BILLING_ENABLED) { const subscription = await getSubscriptionInfo(domain); if ( diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index ccac2e99..b0aa8f5b 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -11,6 +11,7 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { getOrgFromDomain } from "@/data/org"; import { OrgRole } from "@prisma/client"; import { env } from "@/env.mjs"; +import { hasEntitlement } from "@sourcebot/shared"; interface LayoutProps { children: React.ReactNode; @@ -68,6 +69,8 @@ export default async function SettingsLayout( throw new ServiceErrorException(connectionStats); } + const hasPermissionSyncingEntitlement = await hasEntitlement("permission-syncing"); + const sidebarNavItems: SidebarNavItem[] = [ { title: "General", @@ -114,6 +117,12 @@ export default async function SettingsLayout( title: "Analytics", href: `/${domain}/settings/analytics`, }, + ...(hasPermissionSyncingEntitlement ? [ + { + title: "Linked Accounts", + href: `/${domain}/settings/permission-syncing`, + } + ] : []), ...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [ { title: "License", diff --git a/packages/web/src/app/[domain]/settings/permission-syncing/linkButton.tsx b/packages/web/src/app/[domain]/settings/permission-syncing/linkButton.tsx new file mode 100644 index 00000000..49750a3a --- /dev/null +++ b/packages/web/src/app/[domain]/settings/permission-syncing/linkButton.tsx @@ -0,0 +1,30 @@ +'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 new file mode 100644 index 00000000..a3cc04f2 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/permission-syncing/page.tsx @@ -0,0 +1,20 @@ +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; + + const hasPermissionSyncingEntitlement = await hasEntitlement("permission-syncing"); + if (!hasPermissionSyncingEntitlement) { + notFound(); + } + + return ; +} diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx index 9d6f2734..442fb919 100644 --- a/packages/web/src/app/components/authMethodSelector.tsx +++ b/packages/web/src/app/components/authMethodSelector.tsx @@ -8,7 +8,7 @@ import { CredentialsForm } from "@/app/login/components/credentialsForm"; import { DividerSet } from "@/app/components/dividerSet"; import { ProviderButton } from "@/app/components/providerButton"; import { AuthSecurityNotice } from "@/app/components/authSecurityNotice"; -import type { IdentityProviderMetadata } from "@/lib/authProviders"; +import type { IdentityProviderMetadata } from "@/lib/identityProviders"; interface AuthMethodSelectorProps { providers: IdentityProviderMetadata[]; diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx index df284589..539bf781 100644 --- a/packages/web/src/app/invite/page.tsx +++ b/packages/web/src/app/invite/page.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; -import { getIdentityProviderMetadata, IdentityProviderMetadata } from "@/lib/authProviders"; +import { getIdentityProviderMetadata, IdentityProviderMetadata } from "@/lib/identityProviders"; import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; interface InvitePageProps { diff --git a/packages/web/src/app/login/components/loginForm.tsx b/packages/web/src/app/login/components/loginForm.tsx index f24bb8f5..b13e19fb 100644 --- a/packages/web/src/app/login/components/loginForm.tsx +++ b/packages/web/src/app/login/components/loginForm.tsx @@ -6,7 +6,7 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import Link from "next/link"; -import type { IdentityProviderMetadata } from "@/lib/authProviders"; +import type { IdentityProviderMetadata } from "@/lib/identityProviders"; interface LoginFormProps { callbackUrl?: string; diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx index 730ca610..2ce955c5 100644 --- a/packages/web/src/app/login/page.tsx +++ b/packages/web/src/app/login/page.tsx @@ -2,7 +2,7 @@ import { auth } from "@/auth"; import { LoginForm } from "./components/loginForm"; import { redirect } from "next/navigation"; import { Footer } from "@/app/components/footer"; -import { getIdentityProviderMetadata } from "@/lib/authProviders"; +import { getIdentityProviderMetadata } from "@/lib/identityProviders"; import { getOrgFromDomain } from "@/data/org"; import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index d995fa9c..33077a9b 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { AuthMethodSelector } from "@/app/components/authMethodSelector" import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { auth } from "@/auth"; -import { getIdentityProviderMetadata } from "@/lib/authProviders"; +import { getIdentityProviderMetadata } from "@/lib/identityProviders"; import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings"; import { CompleteOnboardingButton } from "./components/completeOnboardingButton"; import { getOrgFromDomain } from "@/data/org"; diff --git a/packages/web/src/app/signup/page.tsx b/packages/web/src/app/signup/page.tsx index 8c2c76ff..fd00c87d 100644 --- a/packages/web/src/app/signup/page.tsx +++ b/packages/web/src/app/signup/page.tsx @@ -3,7 +3,7 @@ import { LoginForm } from "../login/components/loginForm"; import { redirect } from "next/navigation"; import { Footer } from "@/app/components/footer"; import { createLogger } from "@sourcebot/logger"; -import { getIdentityProviderMetadata } from "@/lib/authProviders"; +import { getIdentityProviderMetadata } from "@/lib/identityProviders"; import { getOrgFromDomain } from "@/data/org"; import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 68a2501e..1cb0c6dc 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -27,6 +27,7 @@ export const runtime = 'nodejs'; export type IdentityProvider = { provider: Provider; purpose: "sso" | "integration"; + required?: boolean; } declare module 'next-auth' { diff --git a/packages/web/src/ee/features/permissionSyncing/actions.ts b/packages/web/src/ee/features/permissionSyncing/actions.ts new file mode 100644 index 00000000..29762de1 --- /dev/null +++ b/packages/web/src/ee/features/permissionSyncing/actions.ts @@ -0,0 +1,119 @@ +'use server'; + +import { sew } from "@/actions"; +import { createLogger } from "@sourcebot/logger"; +import { withAuthV2, withMinimumOrgRole } from "@/withAuthV2"; +import { loadConfig } from "@sourcebot/shared"; +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"; + +const logger = createLogger('web-ee-permission-syncing-actions'); + +export const userNeedsToLinkIdentityProvider = 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 linkedAccount = await prisma.account.findFirst({ + where: { + provider: identityProvider.provider, + userId: user.id, + }, + }); + + if (!linkedAccount) { + logger.info(`Required integration identity provider ${identityProvider.provider} account info not found for user ${user.id}`); + return true; + } + } + } + + return false; + }) + ) +); + +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 }) => + withMinimumOrgRole(role, OrgRole.MEMBER, async () => { + 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) { + throw new Error("Provider is not an integration provider"); + } + + // Delete the account + const result = await prisma.account.deleteMany({ + where: { + provider, + userId: user.id, + }, + }); + + logger.info(`Unlinked integration provider ${provider} for user ${user.id}. Deleted ${result.count} account(s).`); + + return { success: true, count: result.count }; + }) + ) +); + +export const skipOptionalProvidersLink = async () => sew(async () => { + const cookieStore = await cookies(); + cookieStore.set(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME, 'true', { + httpOnly: false, // Allow client-side access + maxAge: 365 * 24 * 60 * 60, // 1 year in seconds + }); + return true; +}); \ No newline at end of file diff --git a/packages/web/src/ee/features/permissionSyncing/components/providerBadge.tsx b/packages/web/src/ee/features/permissionSyncing/components/providerBadge.tsx new file mode 100644 index 00000000..522da946 --- /dev/null +++ b/packages/web/src/ee/features/permissionSyncing/components/providerBadge.tsx @@ -0,0 +1,16 @@ +import { Badge } from "@/components/ui/badge"; + +interface ProviderBadgeProps { + required: boolean; +} + +export function ProviderBadge({ required }: ProviderBadgeProps) { + return ( + + {required ? "Required" : "Optional"} + + ); +} diff --git a/packages/web/src/ee/features/permissionSyncing/components/providerIcon.tsx b/packages/web/src/ee/features/permissionSyncing/components/providerIcon.tsx new file mode 100644 index 00000000..3a23d1d5 --- /dev/null +++ b/packages/web/src/ee/features/permissionSyncing/components/providerIcon.tsx @@ -0,0 +1,48 @@ +import Image from "next/image"; +import { ShieldCheck } from "lucide-react"; + +interface ProviderIconProps { + icon?: { + src: string; + className?: string; + } | null; + displayName: string; + size?: "sm" | "md" | "lg"; +} + +const sizeClasses = { + sm: { + container: "h-8 w-8", + icon: "h-4 w-4" + }, + md: { + container: "h-10 w-10", + icon: "h-5 w-5" + }, + lg: { + container: "h-12 w-12", + icon: "h-6 w-6" + } +}; + +export function ProviderIcon({ icon, displayName, size = "md" }: ProviderIconProps) { + const sizes = sizeClasses[size]; + + if (icon) { + return ( +
+ {displayName} +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/packages/web/src/ee/features/permissionSyncing/components/providerInfo.tsx b/packages/web/src/ee/features/permissionSyncing/components/providerInfo.tsx new file mode 100644 index 00000000..14307506 --- /dev/null +++ b/packages/web/src/ee/features/permissionSyncing/components/providerInfo.tsx @@ -0,0 +1,24 @@ +import { getAuthProviderInfo } from "@/lib/utils"; +import { ProviderIcon } from "./providerIcon"; +import { ProviderBadge } from "./providerBadge"; + +interface ProviderInfoProps { + providerId: string; + required: boolean; + showBadge?: boolean; +} + +export function ProviderInfo({ providerId, required, showBadge = true }: ProviderInfoProps) { + const providerInfo = getAuthProviderInfo(providerId); + + return ( + <> +
+ + {providerInfo.displayName} + + {showBadge && } +
+ + ); +} diff --git a/packages/web/src/ee/features/permissionSyncing/linkAccounts.tsx b/packages/web/src/ee/features/permissionSyncing/linkAccounts.tsx new file mode 100644 index 00000000..cad1d435 --- /dev/null +++ b/packages/web/src/ee/features/permissionSyncing/linkAccounts.tsx @@ -0,0 +1,118 @@ +'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 { useRouter } from "next/navigation"; +import { useState } from "react"; +import { ProviderIcon } from "./components/providerIcon"; +import { ProviderInfo } from "./components/providerInfo"; + +interface LinkAccountsProps { + unlinkedAccounts: IdentityProviderMetadata[]; + callbackUrl?: string; +} + +export const LinkAccounts = ({ unlinkedAccounts, 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); + setIsSkipping(false); + } + }; + + // 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 ( + + ); + }; + + 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. + + )} +
+
+ +
+ {requiredProviders.map(renderProviderButton)} + {optionalProviders.map(renderProviderButton)} +
+ {hasOnlyOptionalProviders && ( + + )} +
+
+ ); +}; diff --git a/packages/web/src/ee/features/permissionSyncing/linkButton.tsx b/packages/web/src/ee/features/permissionSyncing/linkButton.tsx new file mode 100644 index 00000000..1b3788e4 --- /dev/null +++ b/packages/web/src/ee/features/permissionSyncing/linkButton.tsx @@ -0,0 +1,31 @@ +'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/ee/features/permissionSyncing/linkedAccountsSettings.tsx b/packages/web/src/ee/features/permissionSyncing/linkedAccountsSettings.tsx new file mode 100644 index 00000000..d5b5ca86 --- /dev/null +++ b/packages/web/src/ee/features/permissionSyncing/linkedAccountsSettings.tsx @@ -0,0 +1,159 @@ +import { withAuthV2 } from "@/withAuthV2"; +import { sew } from "@/actions"; +import { isServiceError, getAuthProviderInfo } from "@/lib/utils"; +import { loadConfig } from "@sourcebot/shared"; +import { env } from "@/env.mjs"; +import { Check, X, ShieldCheck } from "lucide-react"; +import { getUnlinkedIntegrationProviders } from "./actions"; +import { UnlinkButton } from "./unlinkButton"; +import { LinkButton } from "./linkButton"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ProviderIcon } from "./components/providerIcon"; +import { ProviderInfo } from "./components/providerInfo"; + +interface LinkedAccountsSettingsProps { + domain: string; +} + +export async function LinkedAccountsSettings({ domain }: LinkedAccountsSettingsProps) { + const config = await loadConfig(env.CONFIG_PATH); + const integrationProviders = (config.identityProviders ?? []) + .filter(provider => provider.purpose === "integration"); + + // Get user's linked accounts + const getLinkedAccounts = async () => sew(() => + withAuthV2(async ({ prisma, user }) => { + const accounts = await prisma.account.findMany({ + where: { + userId: user.id, + provider: { + in: integrationProviders.map(p => p.provider) + } + }, + select: { + provider: true, + providerAccountId: true, + } + }); + return accounts; + }) + ); + + const linkedAccountsResult = await getLinkedAccounts(); + if (isServiceError(linkedAccountsResult)) { + throw new ServiceErrorException(linkedAccountsResult); + } + + const linkedAccounts = linkedAccountsResult; + + const unlinkedProvidersResult = await getUnlinkedIntegrationProviders(); + if (isServiceError(unlinkedProvidersResult)) { + throw new ServiceErrorException(unlinkedProvidersResult); + } + + return ( +
+
+

Linked Accounts

+

+ Manage your linked integration accounts for permission syncing and code host access. +

+
+ + {/* Show linked accounts as separate cards */} + {integrationProviders.length === 0 ? ( + + +
+ +
+

No integration providers configured

+

+ Contact your administrator to configure integration providers for your organization. +

+
+
+ ) : ( +
+ {integrationProviders.map((provider) => { + const providerInfo = getAuthProviderInfo(provider.provider); + const linkedAccount = linkedAccounts.find( + account => account.provider === provider.provider + ); + const isLinked = !!linkedAccount; + const isRequired = 'required' in provider ? (provider.required as boolean) : true; + + return ( + + +
+
+
+ +
+
+ + + + + {isLinked ? ( +
+
+ + + Connected + +
+ {linkedAccount.providerAccountId && ( + <> + + + {linkedAccount.providerAccountId} + + + )} +
+ ) : ( +
+ + + Not connected + +
+ )} +
+
+
+
+ {isLinked ? ( + + ) : ( + + )} +
+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/packages/web/src/ee/features/permissionSyncing/unlinkButton.tsx b/packages/web/src/ee/features/permissionSyncing/unlinkButton.tsx new file mode 100644 index 00000000..5e61e71a --- /dev/null +++ b/packages/web/src/ee/features/permissionSyncing/unlinkButton.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Unlink, Loader2 } from "lucide-react"; +import { unlinkIntegrationProvider } from "./actions"; +import { isServiceError } from "@/lib/utils"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/components/hooks/use-toast"; + +interface UnlinkButtonProps { + provider: string; + providerName: string; +} + +export const UnlinkButton = ({ provider, providerName }: UnlinkButtonProps) => { + const [isUnlinking, setIsUnlinking] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const handleUnlink = async () => { + if (!confirm(`Are you sure you want to disconnect your ${providerName} account?`)) { + return; + } + + setIsUnlinking(true); + try { + const result = await unlinkIntegrationProvider(provider); + + if (isServiceError(result)) { + toast({ + description: `❌ Failed to disconnect account. ${result.message}`, + variant: "destructive", + }); + setIsUnlinking(false); + return; + } + + toast({ + description: `✅ ${providerName} account disconnected successfully.`, + }); + + // Refresh the page to show updated state + router.refresh(); + } catch (error) { + toast({ + description: `❌ Failed to disconnect account. ${error instanceof Error ? error.message : "Unknown error"}`, + variant: "destructive", + }); + setIsUnlinking(false); + } + }; + + return ( + + ); +}; diff --git a/packages/web/src/ee/features/sso/sso.ts b/packages/web/src/ee/features/sso/sso.ts index 55092032..579f7ceb 100644 --- a/packages/web/src/ee/features/sso/sso.ts +++ b/packages/web/src/ee/features/sso/sso.ts @@ -9,6 +9,7 @@ import { prisma } from "@/prisma"; import { OAuth2Client } from "google-auth-library"; import Credentials from "next-auth/providers/credentials"; import type { User as AuthJsUser } from "next-auth"; +import type { Provider } from "next-auth/providers"; import { onCreateUser } from "@/lib/authUtils"; import { createLogger } from "@sourcebot/logger"; import { hasEntitlement, loadConfig } from "@sourcebot/shared"; @@ -21,7 +22,7 @@ const logger = createLogger('web-sso'); export const getEEIdentityProviders = async (): Promise => { const providers: IdentityProvider[] = []; - const config = env.CONFIG_PATH ? await loadConfig(env.CONFIG_PATH) : undefined; + const config = await loadConfig(env.CONFIG_PATH); const identityProviders = config?.identityProviders ?? []; for (const identityProvider of identityProviders) { @@ -30,14 +31,14 @@ export const getEEIdentityProviders = async (): Promise => { const clientId = await getTokenFromConfig(providerConfig.clientId); const clientSecret = await getTokenFromConfig(providerConfig.clientSecret); const baseUrl = providerConfig.baseUrl ? await getTokenFromConfig(providerConfig.baseUrl) : undefined; - providers.push({ provider: createGitHubProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose }); + providers.push({ provider: createGitHubProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose, required: providerConfig.required ?? true }); } if (identityProvider.provider === "gitlab") { const providerConfig = identityProvider as GitLabIdentityProviderConfig; const clientId = await getTokenFromConfig(providerConfig.clientId); const clientSecret = await getTokenFromConfig(providerConfig.clientSecret); const baseUrl = providerConfig.baseUrl ? await getTokenFromConfig(providerConfig.baseUrl) : undefined; - providers.push({ provider: createGitLabProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose }); + providers.push({ provider: createGitLabProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose, required: providerConfig.required ?? true }); } if (identityProvider.provider === "google") { const providerConfig = identityProvider as GoogleIdentityProviderConfig; @@ -57,49 +58,49 @@ export const getEEIdentityProviders = async (): Promise => { const clientId = await getTokenFromConfig(providerConfig.clientId); const clientSecret = await getTokenFromConfig(providerConfig.clientSecret); const issuer = await getTokenFromConfig(providerConfig.issuer); - providers.push({ provider: createKeycloakProvider(clientId, clientSecret, issuer), purpose: "sso"}); + providers.push({ provider: createKeycloakProvider(clientId, clientSecret, issuer), purpose: "sso" }); } if (identityProvider.provider === "microsoft-entra-id") { const providerConfig = identityProvider as MicrosoftEntraIDIdentityProviderConfig; const clientId = await getTokenFromConfig(providerConfig.clientId); const clientSecret = await getTokenFromConfig(providerConfig.clientSecret); const issuer = await getTokenFromConfig(providerConfig.issuer); - providers.push({ provider: createMicrosoftEntraIDProvider(clientId, clientSecret, issuer), purpose: "sso"}); + providers.push({ provider: createMicrosoftEntraIDProvider(clientId, clientSecret, issuer), purpose: "sso" }); } if (identityProvider.provider === "gcp-iap") { const providerConfig = identityProvider as GCPIAPIdentityProviderConfig; const audience = await getTokenFromConfig(providerConfig.audience); - providers.push({ provider: createGCPIAPProvider(audience), purpose: "sso"}); + providers.push({ provider: createGCPIAPProvider(audience), purpose: "sso" }); } } // @deprecate if (env.AUTH_EE_GITHUB_CLIENT_ID && env.AUTH_EE_GITHUB_CLIENT_SECRET) { - providers.push(createGitHubProvider(env.AUTH_EE_GITHUB_CLIENT_ID, env.AUTH_EE_GITHUB_CLIENT_SECRET, env.AUTH_EE_GITHUB_BASE_URL)); + providers.push({ provider: createGitHubProvider(env.AUTH_EE_GITHUB_CLIENT_ID, env.AUTH_EE_GITHUB_CLIENT_SECRET, env.AUTH_EE_GITHUB_BASE_URL), purpose: "sso" }); } if (env.AUTH_EE_GITLAB_CLIENT_ID && env.AUTH_EE_GITLAB_CLIENT_SECRET) { - providers.push(createGitLabProvider(env.AUTH_EE_GITLAB_CLIENT_ID, env.AUTH_EE_GITLAB_CLIENT_SECRET, env.AUTH_EE_GITLAB_BASE_URL)); + providers.push({ provider: createGitLabProvider(env.AUTH_EE_GITLAB_CLIENT_ID, env.AUTH_EE_GITLAB_CLIENT_SECRET, env.AUTH_EE_GITLAB_BASE_URL), purpose: "sso" }); } if (env.AUTH_EE_GOOGLE_CLIENT_ID && env.AUTH_EE_GOOGLE_CLIENT_SECRET) { - providers.push(createGoogleProvider(env.AUTH_EE_GOOGLE_CLIENT_ID, env.AUTH_EE_GOOGLE_CLIENT_SECRET)); + providers.push({ provider: createGoogleProvider(env.AUTH_EE_GOOGLE_CLIENT_ID, env.AUTH_EE_GOOGLE_CLIENT_SECRET), purpose: "sso" }); } if (env.AUTH_EE_OKTA_CLIENT_ID && env.AUTH_EE_OKTA_CLIENT_SECRET && env.AUTH_EE_OKTA_ISSUER) { - providers.push(createOktaProvider(env.AUTH_EE_OKTA_CLIENT_ID, env.AUTH_EE_OKTA_CLIENT_SECRET, env.AUTH_EE_OKTA_ISSUER)); + providers.push({ provider: createOktaProvider(env.AUTH_EE_OKTA_CLIENT_ID, env.AUTH_EE_OKTA_CLIENT_SECRET, env.AUTH_EE_OKTA_ISSUER), purpose: "sso" }); } if (env.AUTH_EE_KEYCLOAK_CLIENT_ID && env.AUTH_EE_KEYCLOAK_CLIENT_SECRET && env.AUTH_EE_KEYCLOAK_ISSUER) { - providers.push(createKeycloakProvider(env.AUTH_EE_KEYCLOAK_CLIENT_ID, env.AUTH_EE_KEYCLOAK_CLIENT_SECRET, env.AUTH_EE_KEYCLOAK_ISSUER)); + providers.push({ provider: createKeycloakProvider(env.AUTH_EE_KEYCLOAK_CLIENT_ID, env.AUTH_EE_KEYCLOAK_CLIENT_SECRET, env.AUTH_EE_KEYCLOAK_ISSUER), purpose: "sso" }); } if (env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID && env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET && env.AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER) { - providers.push(createMicrosoftEntraIDProvider(env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID, env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET, env.AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER)); + providers.push({ provider: createMicrosoftEntraIDProvider(env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID, env.AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET, env.AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER), purpose: "sso" }); } if (env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) { - providers.push(createGCPIAPProvider(env.AUTH_EE_GCP_IAP_AUDIENCE)); + providers.push({ provider: createGCPIAPProvider(env.AUTH_EE_GCP_IAP_AUDIENCE), purpose: "sso" }); } return providers; diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts index ad4e9f12..40d62cab 100644 --- a/packages/web/src/features/chat/actions.ts +++ b/packages/web/src/features/chat/actions.ts @@ -24,8 +24,8 @@ import { getTokenFromConfig } from "@sourcebot/crypto"; import { ChatVisibility, OrgRole, Prisma } from "@sourcebot/db"; import { LanguageModel } from "@sourcebot/schemas/v3/languageModel.type"; import { Token } from "@sourcebot/schemas/v3/shared.type"; -import { loadConfig } from "@sourcebot/shared"; import { generateText, JSONValue, extractReasoningMiddleware, wrapLanguageModel } from "ai"; +import { loadConfig } from "@sourcebot/shared"; import fs from 'fs'; import { StatusCodes } from "http-status-codes"; import path from 'path'; @@ -355,22 +355,13 @@ export const getConfiguredLanguageModelsInfo = async (): Promise => { - if (!env.CONFIG_PATH) { - return []; - } - - try { - const config = await loadConfig(env.CONFIG_PATH); - return config.models ?? []; - } catch (error) { - console.error(`Failed to load config file ${env.CONFIG_PATH}: ${error}`); - return []; - } + const config = await loadConfig(env.CONFIG_PATH); + return config.models ?? []; } diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index 63eb6a47..b08685b2 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -65,30 +65,28 @@ const initSingleTenancy = async () => { } // Sync anonymous access config from the config file - if (env.CONFIG_PATH) { - const config = await loadConfig(env.CONFIG_PATH); - const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; + const config = await loadConfig(env.CONFIG_PATH); + const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; - if (forceEnableAnonymousAccess) { - if (!hasAnonymousAccessEntitlement) { - logger.warn(`FORCE_ENABLE_ANONYMOUS_ACCESS env var is set to true but anonymous access entitlement is not available. Setting will be ignored.`); - } else { - const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); - if (org) { - const currentMetadata = getOrgMetadata(org); - const mergedMetadata = { - ...(currentMetadata ?? {}), - anonymousAccessEnabled: true, - }; + if (forceEnableAnonymousAccess) { + if (!hasAnonymousAccessEntitlement) { + logger.warn(`FORCE_ENABLE_ANONYMOUS_ACCESS env var is set to true but anonymous access entitlement is not available. Setting will be ignored.`); + } else { + const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); + if (org) { + const currentMetadata = getOrgMetadata(org); + const mergedMetadata = { + ...(currentMetadata ?? {}), + anonymousAccessEnabled: true, + }; - await prisma.org.update({ - where: { id: org.id }, - data: { - metadata: mergedMetadata, - }, - }); - logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`); - } + await prisma.org.update({ + where: { id: org.id }, + data: { + metadata: mergedMetadata, + }, + }); + logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`); } } } diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts index 3cd6e258..21bb97d5 100644 --- a/packages/web/src/lib/constants.ts +++ b/packages/web/src/lib/constants.ts @@ -24,6 +24,7 @@ export const TEAM_FEATURES = [ export const MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME = 'sb.mobile-unsupported-splash-screen-dismissed'; export const AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME = 'sb.agentic-search-tutorial-dismissed'; +export const OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME = 'sb.optional-providers-link-skipped'; // NOTE: changing SOURCEBOT_GUEST_USER_ID may break backwards compatibility since this value is used // to detect old guest users in the DB. If you change this value ensure it doesn't break upgrade flows diff --git a/packages/web/src/lib/authProviders.ts b/packages/web/src/lib/identityProviders.ts similarity index 96% rename from packages/web/src/lib/authProviders.ts rename to packages/web/src/lib/identityProviders.ts index efcb54ff..792e3fec 100644 --- a/packages/web/src/lib/authProviders.ts +++ b/packages/web/src/lib/identityProviders.ts @@ -4,6 +4,7 @@ export interface IdentityProviderMetadata { id: string; name: string; purpose: "sso" | "integration"; + required: boolean; } export const getIdentityProviderMetadata = (): IdentityProviderMetadata[] => { diff --git a/schemas/v3/identityProvider.json b/schemas/v3/identityProvider.json index 76e6c6ae..6216d270 100644 --- a/schemas/v3/identityProvider.json +++ b/schemas/v3/identityProvider.json @@ -19,6 +19,10 @@ }, "baseUrl": { "$ref": "./shared.json#/definitions/Token" + }, + "required": { + "type": "boolean", + "default": true } }, "required": ["provider", "purpose", "clientId", "clientSecret"] @@ -40,6 +44,10 @@ }, "baseUrl": { "$ref": "./shared.json#/definitions/Token" + }, + "required": { + "type": "boolean", + "default": true } }, "required": ["provider", "purpose", "clientId", "clientSecret", "baseUrl"]