diff --git a/AGENTS.md b/AGENTS.md index 61679526c..8e3213386 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ - In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. - When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button. - Check the name field inside each package's package.json to confirm the right name—skip the top-level one. +- While working with forms, always use zod and react-hook-form to validate the form. Take reference implementation from `apps/web/components/admin/settings/sso/new.tsx`. ## Testing instructions diff --git a/apps/docs/public/assets/schools/idp/okta/app-integration-method.png b/apps/docs/public/assets/schools/idp/okta/app-integration-method.png new file mode 100644 index 000000000..6bf563a21 Binary files /dev/null and b/apps/docs/public/assets/schools/idp/okta/app-integration-method.png differ diff --git a/apps/docs/public/assets/schools/idp/okta/applications.png b/apps/docs/public/assets/schools/idp/okta/applications.png new file mode 100644 index 000000000..7cc3eb297 Binary files /dev/null and b/apps/docs/public/assets/schools/idp/okta/applications.png differ diff --git a/apps/docs/public/assets/schools/idp/okta/create-app-configure.png b/apps/docs/public/assets/schools/idp/okta/create-app-configure.png new file mode 100644 index 000000000..37756568f Binary files /dev/null and b/apps/docs/public/assets/schools/idp/okta/create-app-configure.png differ diff --git a/apps/docs/public/assets/schools/idp/okta/create-app-feedback.png b/apps/docs/public/assets/schools/idp/okta/create-app-feedback.png new file mode 100644 index 000000000..19c1c660f Binary files /dev/null and b/apps/docs/public/assets/schools/idp/okta/create-app-feedback.png differ diff --git a/apps/docs/public/assets/schools/idp/okta/create-app-general.png b/apps/docs/public/assets/schools/idp/okta/create-app-general.png new file mode 100644 index 000000000..b65fb27a4 Binary files /dev/null and b/apps/docs/public/assets/schools/idp/okta/create-app-general.png differ diff --git a/apps/docs/public/assets/schools/idp/okta/entry-point.png b/apps/docs/public/assets/schools/idp/okta/entry-point.png new file mode 100644 index 000000000..fa31aa341 Binary files /dev/null and b/apps/docs/public/assets/schools/idp/okta/entry-point.png differ diff --git a/apps/docs/public/assets/schools/idp/okta/saml-signing-certificates.png b/apps/docs/public/assets/schools/idp/okta/saml-signing-certificates.png new file mode 100644 index 000000000..a0a9e6546 Binary files /dev/null and b/apps/docs/public/assets/schools/idp/okta/saml-signing-certificates.png differ diff --git a/apps/docs/public/assets/schools/login-providers-area.png b/apps/docs/public/assets/schools/login-providers-area.png new file mode 100644 index 000000000..4f2564b41 Binary files /dev/null and b/apps/docs/public/assets/schools/login-providers-area.png differ diff --git a/apps/docs/public/assets/schools/reenable-email-login-provider.png b/apps/docs/public/assets/schools/reenable-email-login-provider.png new file mode 100644 index 000000000..9d9a7ce1b Binary files /dev/null and b/apps/docs/public/assets/schools/reenable-email-login-provider.png differ diff --git a/apps/docs/public/assets/schools/sso-checkout-view.png b/apps/docs/public/assets/schools/sso-checkout-view.png new file mode 100644 index 000000000..771b4d990 Binary files /dev/null and b/apps/docs/public/assets/schools/sso-checkout-view.png differ diff --git a/apps/docs/public/assets/schools/sso-configure-icon.png b/apps/docs/public/assets/schools/sso-configure-icon.png new file mode 100644 index 000000000..ac6c06f95 Binary files /dev/null and b/apps/docs/public/assets/schools/sso-configure-icon.png differ diff --git a/apps/docs/public/assets/schools/sso-enable-checkbox.png b/apps/docs/public/assets/schools/sso-enable-checkbox.png new file mode 100644 index 000000000..e3a7e1a22 Binary files /dev/null and b/apps/docs/public/assets/schools/sso-enable-checkbox.png differ diff --git a/apps/docs/public/assets/schools/sso-idp-configuration-example.png b/apps/docs/public/assets/schools/sso-idp-configuration-example.png new file mode 100644 index 000000000..9d75e6e62 Binary files /dev/null and b/apps/docs/public/assets/schools/sso-idp-configuration-example.png differ diff --git a/apps/docs/public/assets/schools/sso-login-view.png b/apps/docs/public/assets/schools/sso-login-view.png new file mode 100644 index 000000000..6583181b8 Binary files /dev/null and b/apps/docs/public/assets/schools/sso-login-view.png differ diff --git a/apps/docs/public/assets/schools/sso-provider-configuration.png b/apps/docs/public/assets/schools/sso-provider-configuration.png new file mode 100644 index 000000000..d7008ea8a Binary files /dev/null and b/apps/docs/public/assets/schools/sso-provider-configuration.png differ diff --git a/apps/docs/public/assets/schools/sso-save-config-button.png b/apps/docs/public/assets/schools/sso-save-config-button.png new file mode 100644 index 000000000..643b31cc9 Binary files /dev/null and b/apps/docs/public/assets/schools/sso-save-config-button.png differ diff --git a/apps/docs/src/config.ts b/apps/docs/src/config.ts index 5406a66b4..97e90a8d5 100644 --- a/apps/docs/src/config.ts +++ b/apps/docs/src/config.ts @@ -115,6 +115,7 @@ export const SIDEBAR: Sidebar = { { text: "Create a school", link: "en/schools/create" }, { text: "Use custom domain", link: "en/schools/add-custom-domain" }, { text: "Set up payments", link: "en/schools/set-up-payments" }, + { text: "Single Sign-On", link: "en/schools/sso" }, { text: "Delete a school", link: "en/schools/delete" }, ], Users: [ diff --git a/apps/docs/src/pages/en/schools/sso.md b/apps/docs/src/pages/en/schools/sso.md new file mode 100644 index 000000000..1e75f3e47 --- /dev/null +++ b/apps/docs/src/pages/en/schools/sso.md @@ -0,0 +1,117 @@ +--- +title: Set up Single Sign On (SSO) +description: Learn how to set up Single Sign On (SSO) +layout: ../../../layouts/MainLayout.astro +--- + +Using SSO, you can authenticate users with their existing accounts on platforms like [Okta](https://www.okta.com/), [OneLogin](https://www.onelogin.com/), [Azure AD](https://azure.microsoft.com/en-us/services/active-directory/), etc. + +> The feature is currently in alpha, which means you may encounter bugs. Please report them in our Discord group if you run into any. + +To use this feature on [courseLit.app](https://courselit.app), you need to be on the Enterprise plan. For self-hosted instances, this feature is available by default. + +## Steps to set up SSO + +1. Subscribe to the [Enterprise](https://app.courselit.app/account/billing) plan, if you haven't, to unlock the feature. Ignore this step for self-hosted instances. + +2. In the CourseLit dashboard, go to `Settings` -> `Miscellaneous` -> `Login providers`. + + ![Login providers area](/assets/schools/login-providers-area.png) + +3. Click on the Cog icon next to the SSO provider to open SSO configuration. + + ![SSO configuration button](/assets/schools/sso-configure-icon.png) + +4. In the `SSO Provider` screen, use the `School Settings` to configure your IdP provider. Refer to the sections below to see how to configure your IdP provider. + + The following is a description of the fields under this panel: + + - **SAML ACS URL**: This is the URL that your IdP will send the SAML response to. This is usually `https://.courselit.app/api/auth/sso/saml2/sp/acs/sso` + - **Audience URI (SP Entity ID)**: This is the URL that your IdP will use to validate the SAML response. This is usually `https://.courselit.app/api/auth/sso/saml2/sp/metadata?providerId=sso` + +5. After configuring the IdP provider, obtain the required settings from it and populate the values in the `IDP Configuration` panel. + + The following is a description of the fields under this panel: + + - **Entry point**: This is the URL CourseLit will use to send the SAML request to your IdP. + - **Certificate**: This is the certificate that your IdP will use to validate the SAML response. + - **IDP Metadata**: This is the metadata that your IdP will use to validate the SAML response. + + Here is an example configuration for Okta: + + ![Okta SSO Configuration](/assets/schools/sso-idp-configuration-example.png) + +6. Click on the `Save` button to save the configuration. + + ![SSO Configuration save button](/assets/schools/sso-save-config-button.png) + +7. Go back to the `Login providers` screen and enable the SSO provider. + + ![Enable SSO provider](/assets/schools/sso-enable-checkbox.png) + +## Setup IdP + +### Okta + +1. Go to Okta dashboard and click on `Applications` -> `Applications`. +2. Click on `Create App Integration`. +3. Select `SAML 2.0` on the `Sign-in method` popup and click on `Next`. +4. On the `Create SAML Integration` screen, in the `General Settings` tab, enter `App name` and click on `Next`. +5. In the `Configure SAML` tab, enter the `SAML ACS URL` (obtained from CourseLit) in the `Single sign-on URL` field and `Audience URI (SP Entity ID)` (obtained from CourseLit) in the `Audience URI (SP Entity ID)` field and click on `Next`. +6. In the `Feedback` tab, select the `internal app` option and click on `Finish`. +7. You will be taken to the newly created app's settings. Your Okta IdP is now configured. +8. Next, let's obtain the `Entry point`, `IdP metadata` and `Certificate` from Okta. From the `Sign On` tab, obtain the following: +
+
+ + - **Entry point**: We can infer this from the Metadata URL. It is usually `https://.okta.com/app//sso/saml2` + + ![Okta entry point](/assets/schools/idp/okta/entry-point.png) +
+
+ + - **IdP metadata** and **Certificate**: + To obtain these, scroll down on the same page and locate the `SAML Signing Certificates` section. Click on the `Actions` button next to the `SHA-2` and copy the IdP metadata and download the certificate. + + ![Okta IdP metadata](/assets/schools/idp/okta/saml-signing-certificates.png) + +9. Enter the values obtained in the `IDP Configuration` panel. +10. The Okta IdP is now configured. + +## Customer's experience + +When the SSO login provider is configured and enabled, the customer will see a `Login with SSO` button on the login page and checkout page. + +### 1. Login page + +![SSO login view](/assets/schools/sso-login-view.png) + +### 2. Checkout page + +![SSO checkout view](/assets/schools/sso-checkout-view.png) + +## Troubleshooting + +### 1. Email login is disabled and now I am locked out + +#### a. Cloud-hosted (courselit.app) + +You can re-enable the email provider from the [CourseLit](https://app.courselit.app) dashboard. + +![Re-enable email login provider](/assets/schools/reenable-email-login-provider.png) + +#### b. Self-hosted + +You need to log in to your school's MongoDB instance and run the following query to re-enable the email provider: + +```javascript +db.domains.updateMany({}, { $addToSet: { "settings.logins": "email" } }); +``` + +### 2. Can I add multiple SSO providers? + +Since this feature is currently in alpha, you can only add one SSO provider at a time. We want to make sure that the feature is stable before adding more providers. + +## Stuck somewhere? + +We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/web/.migrations/13-12-25_23-07-init-login-settings.js b/apps/web/.migrations/13-12-25_23-07-init-login-settings.js new file mode 100644 index 000000000..6afae531e --- /dev/null +++ b/apps/web/.migrations/13-12-25_23-07-init-login-settings.js @@ -0,0 +1,28 @@ +import mongoose from "mongoose"; + +mongoose.connect(process.env.DB_CONNECTION_STRING, { + useNewUrlParser: true, + useUnifiedTopology: true, +}); + +const SettingsSchema = new mongoose.Schema({ + logins: { type: [String] }, +}); + +const DomainSchema = new mongoose.Schema({ + name: { type: String, required: true, unique: true }, + settings: SettingsSchema, +}); + +const Domain = mongoose.model("Domain", DomainSchema); + +const addEmailLoginToDomainSettings = async () => { + console.log("🏁 Migrating login settings"); + await Domain.updateMany({}, { $set: { "settings.logins": ["email"] } }); + console.log("🏁 Migrated login settings"); +}; + +(async () => { + await addEmailLoginToDomainSettings(); + mongoose.connection.close(); +})(); diff --git a/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx b/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx index d0cdf7f8d..9a90ddd37 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx @@ -3,11 +3,13 @@ import { AddressContext } from "@components/contexts"; import Checkout, { Product } from "@components/public/payments/checkout"; import { Constants, PaymentPlan, Course } from "@courselit/common-models"; +import type { MembershipEntityType } from "@courselit/common-models"; import { useToast } from "@courselit/components-library"; import { FetchBuilder } from "@courselit/utils"; import { TOAST_TITLE_ERROR } from "@ui-config/strings"; import { useSearchParams } from "next/navigation"; import { useCallback, useContext, useEffect, useState } from "react"; +import type { SSOProvider } from "../login/page"; const { MembershipEntityType } = Constants; @@ -21,6 +23,7 @@ export default function ProductCheckout() { const [product, setProduct] = useState(null); const [paymentPlans, setPaymentPlans] = useState([]); const [includedProducts, setIncludedProducts] = useState([]); + const [ssoProvider, setSSOProvider] = useState(); const getIncludedProducts = useCallback(async () => { const query = ` @@ -94,6 +97,10 @@ export default function ProductCheckout() { } defaultPaymentPlan } + ssoProvider: getSSOProvider { + providerId + domain + } } `; const fetch = new FetchBuilder() @@ -119,6 +126,9 @@ export default function ProductCheckout() { description: "Course not found", }); } + if (response.ssoProvider) { + setSSOProvider(response.ssoProvider); + } } catch (err: any) { toast({ title: TOAST_TITLE_ERROR, @@ -154,6 +164,10 @@ export default function ProductCheckout() { joiningReasonText defaultPaymentPlan } + ssoProvider: getSSOProvider { + providerId + domain + } } `; const fetch = new FetchBuilder() @@ -180,6 +194,9 @@ export default function ProductCheckout() { description: "Community not found", }); } + if (response.ssoProvider) { + setSSOProvider(response.ssoProvider); + } } catch (err: any) { toast({ title: TOAST_TITLE_ERROR, @@ -214,6 +231,9 @@ export default function ProductCheckout() { product={product} paymentPlans={paymentPlans} includedProducts={includedProducts} + ssoProvider={ssoProvider} + type={entityType as MembershipEntityType | undefined} + id={entityId as string | undefined} /> ); } diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx index 53a094613..a58865f0e 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx @@ -3,6 +3,7 @@ import { AddressContext, ServerConfigContext, + SiteInfoContext, ThemeContext, } from "@components/contexts"; import { @@ -32,12 +33,21 @@ import { TriangleAlert } from "lucide-react"; import { useRecaptcha } from "@/hooks/use-recaptcha"; import RecaptchaScriptLoader from "@/components/recaptcha-script-loader"; import { checkPermission } from "@courselit/utils"; -import { Profile } from "@courselit/common-models"; +import { Constants, Profile } from "@courselit/common-models"; import { getUserProfile } from "../../helpers"; import { ADMIN_PERMISSIONS } from "@ui-config/constants"; import { authClient } from "@/lib/auth-client"; -export default function LoginForm({ redirectTo }: { redirectTo?: string }) { +export default function LoginForm({ + redirectTo, + ssoProvider, +}: { + redirectTo?: string; + ssoProvider?: { + providerId: string; + domain: string; + }; +}) { const { theme } = useContext(ThemeContext); const [showCode, setShowCode] = useState(false); const [email, setEmail] = useState(""); @@ -49,6 +59,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { const { executeRecaptcha } = useRecaptcha(); const address = useContext(AddressContext); const codeInputRef = useRef(null); + const siteinfo = useContext(SiteInfoContext); const validateRecaptcha = useCallback(async (): Promise => { if (!serverConfig.recaptchaSiteKey) { @@ -182,113 +193,141 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
-
- {error && ( -
- -
- {error} -
-
- )} - {!showCode && ( -
- - {LOGIN_FORM_LABEL} - -
- - setEmail(e.target.value) - } - theme={theme.theme} - /> - - - {LOGIN_FORM_DISCLAIMER} - - - Terms - - - -
-
- )} - {showCode && ( -
- - {LOGIN_CODE_INTIMATION_MESSAGE}{" "} - {email} - -
- - setCode(e.target.value) - } - theme={theme.theme} - ref={codeInputRef} - /> - - {/*
*/} - -
- - {LOGIN_NO_CODE} -
+ )} + {!showCode && ( +
+ + {LOGIN_FORM_LABEL} + +
- + setEmail(e.target.value) + } theme={theme.theme} - className="text-xs" + /> + - -
-
+ : BTN_LOGIN_GET_CODE} + + +
+ )} + {showCode && ( +
+ + {LOGIN_CODE_INTIMATION_MESSAGE}{" "} + {email} + +
+ + setCode(e.target.value) + } + theme={theme.theme} + ref={codeInputRef} + /> + + {/*
*/} + +
+ + {LOGIN_NO_CODE} + + +
+
+ )} + )} + {siteinfo.logins?.includes( + Constants.LoginProvider.SSO, + ) && + ssoProvider && ( + + )} + + {LOGIN_FORM_DISCLAIMER} + + Terms + + diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/page.tsx index a55992fb4..f67ed8d51 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/login/page.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/login/page.tsx @@ -2,6 +2,9 @@ import { auth } from "@/auth"; import { redirect } from "next/navigation"; import LoginForm from "./login-form"; import { headers } from "next/headers"; +import { getAddressFromHeaders } from "@/app/actions"; +import { FetchBuilder } from "@courselit/utils"; +import { error } from "@/services/logger"; export default async function LoginPage({ searchParams, @@ -14,10 +17,48 @@ export default async function LoginPage({ }); const redirectTo = (await searchParams).redirect as string | undefined; + const address = await getAddressFromHeaders(headers); if (session) { redirect(redirectTo || "/dashboard"); } - return ; + return ( + + ); } + +export type SSOProvider = { + providerId: string; + domain: string; +}; + +export const getSSOProvider = async ( + backend: string, +): Promise => { + const query = ` + query { + ssoProvider: getSSOProvider { + providerId + domain + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${backend}/api/graph`) + .setPayload({ query }) + .setIsGraphQLEndpoint(true) + .build(); + + try { + const response = await fetch.exec(); + return response.ssoProvider; + } catch (e: any) { + error(`Error in fetching SSO provider`, { + stack: e.stack, + }); + } +}; diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/apikeys/new/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/apikeys/new/page.tsx index 22abb374a..d35c8f0b1 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/apikeys/new/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/apikeys/new/page.tsx @@ -6,7 +6,11 @@ import { checkPermission } from "@courselit/utils"; import { AddressContext, ProfileContext } from "@components/contexts"; import { UIConstants } from "@courselit/common-models"; import DashboardContent from "@components/admin/dashboard-content"; -import { SITE_SETTINGS_PAGE_HEADING } from "@ui-config/strings"; +import { + APIKEY_NEW_HEADER, + SITE_MISCELLANEOUS_SETTING_HEADER, + SITE_SETTINGS_PAGE_HEADING, +} from "@ui-config/strings"; import dynamic from "next/dynamic"; const { permissions } = UIConstants; @@ -14,7 +18,13 @@ const ApikeyNew = dynamic( () => import("@/components/admin/settings/apikey/new"), ); -const breadcrumbs = [{ label: SITE_SETTINGS_PAGE_HEADING, href: "#" }]; +const breadcrumbs = [ + { + label: SITE_SETTINGS_PAGE_HEADING, + href: `/dashboard/settings?tab=${SITE_MISCELLANEOUS_SETTING_HEADER}`, + }, + { label: APIKEY_NEW_HEADER, href: "#" }, +]; export default function Page() { const address = useContext(AddressContext); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/login-provider/sso/layout.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/login-provider/sso/layout.tsx new file mode 100644 index 000000000..48678845b --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/login-provider/sso/layout.tsx @@ -0,0 +1,16 @@ +import { SSO_PROVIDER_HEADER } from "@ui-config/strings"; +import type { Metadata, ResolvingMetadata } from "next"; +import { ReactNode } from "react"; + +export async function generateMetadata( + _: any, + parent: ResolvingMetadata, +): Promise { + return { + title: `${SSO_PROVIDER_HEADER} | ${(await parent)?.title?.absolute}`, + }; +} + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/login-provider/sso/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/login-provider/sso/page.tsx new file mode 100644 index 000000000..a8d9f1d5f --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/login-provider/sso/page.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useContext } from "react"; +import LoadingScreen from "@components/admin/loading-screen"; +import { checkPermission } from "@courselit/utils"; +import { + AddressContext, + FeaturesContext, + ProfileContext, +} from "@components/contexts"; +import { Constants, UIConstants } from "@courselit/common-models"; +import DashboardContent from "@components/admin/dashboard-content"; +import { + SITE_MISCELLANEOUS_SETTING_HEADER, + SITE_SETTINGS_PAGE_HEADING, + SSO_PROVIDER_HEADER, +} from "@ui-config/strings"; +import dynamic from "next/dynamic"; +import { redirect } from "next/navigation"; +const { permissions } = UIConstants; + +const SSOProvider = dynamic(() => import("@/components/admin/settings/sso")); + +const breadcrumbs = [ + { + label: SITE_SETTINGS_PAGE_HEADING, + href: `/dashboard/settings?tab=${SITE_MISCELLANEOUS_SETTING_HEADER}`, + }, + { label: SSO_PROVIDER_HEADER, href: "#" }, +]; + +export default function Page() { + const address = useContext(AddressContext); + const { profile } = useContext(ProfileContext); + const features = useContext(FeaturesContext); + + if (!features.includes(Constants.Features.SSO)) { + redirect( + `/dashboard/settings?tab=${SITE_MISCELLANEOUS_SETTING_HEADER}`, + ); + } + + if ( + !profile || + !checkPermission(profile.permissions!, [permissions.manageSettings]) + ) { + return ; + } + + return ( + + + + ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/page.tsx b/apps/web/app/(with-contexts)/dashboard/page.tsx index 747fd769a..accf2a30a 100644 --- a/apps/web/app/(with-contexts)/dashboard/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/page.tsx @@ -3,10 +3,15 @@ import { getProfile } from "../action"; import { Profile } from "@courselit/common-models"; import { checkPermission } from "@courselit/utils"; import { ADMIN_PERMISSIONS } from "@ui-config/constants"; +import { auth } from "@/auth"; export default async function Page() { const profile = (await getProfile()) as Profile; + if (!profile) { + await auth.api.signOut(); + } + if (checkPermission(profile?.permissions, ADMIN_PERMISSIONS)) { redirect("/dashboard/overview"); } else { diff --git a/apps/web/app/(with-contexts)/layout-with-context.tsx b/apps/web/app/(with-contexts)/layout-with-context.tsx index c95c697b7..b108c3051 100644 --- a/apps/web/app/(with-contexts)/layout-with-context.tsx +++ b/apps/web/app/(with-contexts)/layout-with-context.tsx @@ -8,13 +8,14 @@ import { useCallback, startTransition, } from "react"; -import { SiteInfo, ServerConfig } from "@courselit/common-models"; +import { SiteInfo, ServerConfig, Features } from "@courselit/common-models"; import { AddressContext, ProfileContext, SiteInfoContext, ServerConfigContext, ThemeContext, + FeaturesContext, } from "@components/contexts"; import { Toaster, useToast } from "@courselit/components-library"; import { TOAST_TITLE_ERROR } from "@ui-config/strings"; @@ -33,6 +34,7 @@ function LayoutContent({ theme: initialTheme, config, session, + features, }: { address: string; children: ReactNode; @@ -40,6 +42,7 @@ function LayoutContent({ theme: Theme; config: ServerConfig; session: BetterAuthSession; + features: Features[]; }) { const [profile, setProfile] = useState(defaultState.profile); const [theme, setTheme] = useState(initialTheme); @@ -77,25 +80,29 @@ function LayoutContent({ frontend: address, }} > - - - - - + + + + - {children} - - - - - - + + + {children} + + + + + + + + ); } @@ -107,6 +114,7 @@ export default function Layout(props: { theme: Theme; config: ServerConfig; session: BetterAuthSession; + features: Features[]; }) { return ( diff --git a/apps/web/app/(with-contexts)/layout.tsx b/apps/web/app/(with-contexts)/layout.tsx index 2f55e719e..abbad60af 100644 --- a/apps/web/app/(with-contexts)/layout.tsx +++ b/apps/web/app/(with-contexts)/layout.tsx @@ -32,6 +32,7 @@ export default async function Layout({ theme={siteSetup?.theme || defaultState.theme} config={config} session={session} + features={siteSetup?.features || defaultState.features} > {children} @@ -61,4 +62,5 @@ const formatSiteInfo = (siteinfo?: SiteInfo) => ({ razorpayKey: siteinfo?.razorpayKey || defaultState.siteinfo.razorpayKey, lemonsqueezyKey: siteinfo?.lemonsqueezyKey || defaultState.siteinfo.lemonsqueezyKey, + logins: siteinfo?.logins || defaultState.siteinfo.logins, }); diff --git a/apps/web/app/api/auth/sso/saml2/callback/[providerId]/route.ts b/apps/web/app/api/auth/sso/saml2/callback/[providerId]/route.ts new file mode 100644 index 000000000..6ad530677 --- /dev/null +++ b/apps/web/app/api/auth/sso/saml2/callback/[providerId]/route.ts @@ -0,0 +1,33 @@ +import { als } from "@/async-local-storage"; +import { auth } from "@/auth"; +import { NextResponse } from "next/server"; +import { toNextJsHandler } from "better-auth/next-js"; + +const handlers = toNextJsHandler(auth); + +export async function POST(req: Request) { + const map = new Map(); + map.set("domain", req.headers.get("domain")); + map.set("domainId", req.headers.get("domainId")); + als.enterWith(map); + + return handlers.POST(req); +} + +// Required: IdP-initiated flows redirect to this URL after POST +export async function GET(req: Request) { + const map = new Map(); + map.set("domain", req.headers.get("domain")); + map.set("domainId", req.headers.get("domainId")); + als.enterWith(map); + + const url = new URL(req.url); + url.host = req.headers.get("x-forwarded-host") || ""; + url.protocol = req.headers.get("x-forwarded-proto") || ""; + url.port = + process.env.NODE_ENV === "production" + ? "" + : req.headers.get("x-forwarded-port") || ""; + + return NextResponse.redirect(new URL("/dashboard", url)); +} diff --git a/apps/web/app/verify-domain/route.ts b/apps/web/app/verify-domain/route.ts index e5571ceb9..b683b1df2 100644 --- a/apps/web/app/verify-domain/route.ts +++ b/apps/web/app/verify-domain/route.ts @@ -7,6 +7,7 @@ import { headers } from "next/headers"; import connectToDatabase from "../../services/db"; import { warn } from "@/services/logger"; import SubscriberModel, { Subscriber } from "@models/Subscriber"; +import { Constants } from "@courselit/common-models"; const { domainNameForSingleTenancy, schoolNameForSingleTenancy } = constants; @@ -107,8 +108,6 @@ export async function GET(req: Request) { const currentDate = new Date(); const dateAfter24Hours = new Date(currentDate.getTime() + 86400000); - // domain.checkSubscriptionStatusAfter = dateAfter24Hours; - // await (domain as any).save({ timestamps: true }); await DomainModel.findOneAndUpdate( { _id: domain!._id }, { $set: { checkSubscriptionStatusAfter: dateAfter24Hours } }, @@ -145,7 +144,13 @@ export async function GET(req: Request) { }, settings: { title: schoolNameForSingleTenancy, + logins: [Constants.LoginProvider.EMAIL], }, + features: [ + Constants.Features.SSO, + Constants.Features.API, + Constants.Features.LOG, + ], }, { upsert: true, @@ -179,7 +184,7 @@ export async function GET(req: Request) { } } - return Response.json({ + const payload = { success: true, domain: domain!.name, domainId: domain!._id.toString(), @@ -187,7 +192,10 @@ export async function GET(req: Request) { domainEmail: domain!.email, domainTitle: domain!.settings?.title, hideCourseLitBranding: domain!.settings?.hideCourseLitBranding, - }); + ssoTrustedDomain: domain!.settings?.ssoTrustedDomain, + }; + + return Response.json(payload); } async function getSubscriberName(email: string): Promise { diff --git a/apps/web/auth.ts b/apps/web/auth.ts index fb993ccf5..10922beb9 100644 --- a/apps/web/auth.ts +++ b/apps/web/auth.ts @@ -11,6 +11,7 @@ import { mongodbAdapter } from "@/ba-multitenant-adapter"; import { updateUserAfterCreationViaAuth } from "./graphql/users/logic"; import UserModel from "@models/User"; import { getBackendAddress } from "./app/actions"; +import { sso } from "@better-auth/sso"; const client = new MongoClient( process.env.DB_CONNECTION_STRING || "mongodb://localhost:27017", @@ -20,6 +21,12 @@ const db = client.db(); const config: any = { appName: "CourseLit", secret: process.env.AUTH_SECRET, + account: { + accountLinking: { + enabled: true, + trustedProviders: ["sso", "email-otp"], + }, + }, advanced: { cookiePrefix: "courselit", }, @@ -34,17 +41,20 @@ const config: any = { async sendVerificationOTP({ email, otp, type }, ctx) { const emailBody = pug.render(MagicCodeEmailTemplate, { code: otp, - hideCourseLitBranding: - ctx!.headers?.get("hidecourselitbranding") || false, + hideCourseLitBranding: ctx!.headers?.get( + "hidecourselitbranding", + ) + ? ctx!.headers?.get("hidecourselitbranding") === "true" + : false, }); await addMailJob({ to: [email], - subject: `${responses.sign_in_mail_prefix} ${ctx!.headers?.get("domain")}`, + subject: `${responses.sign_in_mail_prefix} ${ctx!.headers?.get("host")}`, body: emailBody, from: generateEmailFrom({ name: - ctx!.headers?.get("domainTitle") || + ctx!.headers?.get("domaintitle") || ctx!.headers?.get("domain") || "", email: @@ -63,7 +73,7 @@ const config: any = { (await UserModel.findOne({ _id: user.id }) .select("userId") .lean()) as unknown as any - ).userId, + )?.userId, }, session: { ...session, @@ -71,6 +81,17 @@ const config: any = { }, }; }), + sso({ + saml: { + enableInResponseToValidation: true, + requestTTL: 10 * 60 * 1000, // 10 minutes + clockSkew: 5 * 60 * 1000, // 5 minutes + requireTimestamps: true, + }, + fields: { + domain: "domain_string", + }, + }), ], databaseHooks: { user: { @@ -92,8 +113,11 @@ const config: any = { }, }, trustedOrigins: async (request: Request) => { - const backendAddress = await getBackendAddress(request.headers); - return [backendAddress]; + const origins: string[] = [await getBackendAddress(request.headers)]; + if (request.headers.get("ssotrusteddomain")) { + origins.push(request.headers.get("ssotrusteddomain")!); + } + return origins; }, }; diff --git a/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx index bd2f832d1..b745faa77 100644 --- a/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx +++ b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx @@ -39,6 +39,11 @@ import { SIDEBAR_MENU_PAGES, SIDEBAR_MENU_SETTINGS, SIDEBAR_MENU_USERS, + SITE_CUSTOMISATIONS_SETTING_HEADER, + SITE_MISCELLANEOUS_SETTING_HEADER, + SITE_SETTINGS_SECTION_GENERAL, + SITE_SETTINGS_SECTION_MAILS, + SITE_SETTINGS_SECTION_PAYMENT, } from "@ui-config/strings"; import { NavSecondary } from "./nav-secondary"; import { usePathname, useSearchParams } from "next/navigation"; @@ -242,35 +247,39 @@ function getSidebarItems({ if (profile.permissions!.includes(permissions.manageSettings)) { const items = [ { - title: "Branding", - url: "/dashboard/settings?tab=Branding", + title: SITE_SETTINGS_SECTION_GENERAL, + url: `/dashboard/settings?tab=${SITE_SETTINGS_SECTION_GENERAL}`, isActive: - `${path}?tab=${tab}` === "/dashboard/settings?tab=Branding", + `${path}?tab=${tab}` === + `/dashboard/settings?tab=${SITE_SETTINGS_SECTION_GENERAL}`, }, { - title: "Payment", - url: "/dashboard/settings?tab=Payment", + title: SITE_SETTINGS_SECTION_PAYMENT, + url: `/dashboard/settings?tab=${SITE_SETTINGS_SECTION_PAYMENT}`, isActive: - `${path}?tab=${tab}` === "/dashboard/settings?tab=Payment", + `${path}?tab=${tab}` === + `/dashboard/settings?tab=${SITE_SETTINGS_SECTION_PAYMENT}`, }, { - title: "Mails", - url: "/dashboard/settings?tab=Mails", + title: SITE_SETTINGS_SECTION_MAILS, + url: `/dashboard/settings?tab=${SITE_SETTINGS_SECTION_MAILS}`, isActive: - `${path}?tab=${tab}` === "/dashboard/settings?tab=Mails", + `${path}?tab=${tab}` === + `/dashboard/settings?tab=${SITE_SETTINGS_SECTION_MAILS}`, }, { - title: "Code injection", - url: "/dashboard/settings?tab=Code%20Injection", + title: SITE_CUSTOMISATIONS_SETTING_HEADER, + url: `/dashboard/settings?tab=${encodeURIComponent(SITE_CUSTOMISATIONS_SETTING_HEADER)}`, isActive: `${path}?tab=${tab}` === - "/dashboard/settings?tab=Code Injection", + `/dashboard/settings?tab=${SITE_CUSTOMISATIONS_SETTING_HEADER}`, }, { - title: "API Keys", - url: "/dashboard/settings?tab=API%20Keys", + title: SITE_MISCELLANEOUS_SETTING_HEADER, + url: `/dashboard/settings?tab=${SITE_MISCELLANEOUS_SETTING_HEADER}`, isActive: - `${path}?tab=${tab}` === "/dashboard/settings?tab=API Keys", + `${path}?tab=${tab}` === + `/dashboard/settings?tab=${SITE_MISCELLANEOUS_SETTING_HEADER}`, }, ]; navMainItems.push({ diff --git a/apps/web/components/admin/dashboard-skeleton/nav-main.tsx b/apps/web/components/admin/dashboard-skeleton/nav-main.tsx index 0af6110a6..a1195536f 100644 --- a/apps/web/components/admin/dashboard-skeleton/nav-main.tsx +++ b/apps/web/components/admin/dashboard-skeleton/nav-main.tsx @@ -18,6 +18,8 @@ import { SidebarMenuSubItem, } from "@/components/ui/sidebar"; import Link from "next/link"; +import { Chip } from "@courselit/components-library"; +import { BETA_LABEL } from "@ui-config/strings"; export function NavMain({ items, @@ -52,11 +54,7 @@ export function NavMain({ {item.icon && } {item.title} - {item.beta && ( - - Beta - - )} + {item.beta && {BETA_LABEL}} @@ -94,11 +92,7 @@ export function NavMain({ {item.icon && } {item.title} - {item.beta && ( - - Beta - - )} + {item.beta && {BETA_LABEL}} diff --git a/apps/web/components/admin/settings/apikey/new.tsx b/apps/web/components/admin/settings/apikey/new.tsx index a68648b57..695d16ef9 100644 --- a/apps/web/components/admin/settings/apikey/new.tsx +++ b/apps/web/components/admin/settings/apikey/new.tsx @@ -1,9 +1,8 @@ import { Address } from "@courselit/common-models"; import { - Button, + // Button, Form, FormField, - IconButton, useToast, } from "@courselit/components-library"; import { FetchBuilder } from "@courselit/utils"; @@ -22,6 +21,7 @@ import { import Link from "next/link"; import { FormEvent, useState } from "react"; import { Clipboard } from "@courselit/icons"; +import { Button } from "@components/ui/button"; interface NewApikeyProps { address: Address; @@ -107,13 +107,14 @@ export default function NewApikey({

- - +
@@ -122,11 +123,13 @@ export default function NewApikey({ )} {!apikey && (
- - +
)} diff --git a/apps/web/components/admin/settings/index.tsx b/apps/web/components/admin/settings/index.tsx index d0487214c..4bd92981c 100644 --- a/apps/web/components/admin/settings/index.tsx +++ b/apps/web/components/admin/settings/index.tsx @@ -23,14 +23,6 @@ import { BUTTON_SAVE, SITE_SETTINGS_PAYMENT_METHOD_NONE_LABEL, SITE_CUSTOMISATIONS_SETTING_CODEINJECTION_BODY, - SITE_APIKEYS_SETTING_HEADER, - APIKEY_NEW_BUTTON, - APIKEY_EXISTING_HEADER, - APIKEY_EXISTING_TABLE_HEADER_CREATED, - APIKEY_EXISTING_TABLE_HEADER_NAME, - APIKEY_REMOVE_BTN, - APIKEY_REMOVE_DIALOG_HEADER, - APIKYE_REMOVE_DIALOG_DESC, SITE_MAILS_HEADER, SITE_MAILING_ADDRESS_SETTING_HEADER, SITE_MAILING_ADDRESS_SETTING_EXPLANATION, @@ -48,7 +40,7 @@ import { SITE_SETTINGS_LEMONSQUEEZY_SUB_MONTHLY_TEXT, SITE_SETTINGS_LEMONSQUEEZY_SUB_YEARLY_TEXT, SETTINGS_RESOURCE_PAYMENT, - SETTINGS_RESOURCE_API, + SITE_MISCELLANEOUS_SETTING_HEADER, } from "@/ui-config/strings"; import { FetchBuilder, capitalize } from "@courselit/utils"; import { decode, encode } from "base-64"; @@ -61,16 +53,9 @@ import { Tabbs, Form, FormField, - Button, - Link, - Table, - TableHead, - TableBody, - TableRow, - Dialog2, PageBuilderPropertyHeader, - Checkbox, useToast, + Checkbox, } from "@courselit/components-library"; import { useRouter } from "next/navigation"; import { @@ -84,6 +69,10 @@ import { Copy, Info } from "lucide-react"; import { Input } from "@components/ui/input"; import Resources from "@components/resources"; import { AddressContext } from "@components/contexts"; +import { Button } from "@components/ui/button"; +import dynamic from "next/dynamic"; + +const MiscellaneousTab = dynamic(() => import("./tabs/miscellaneous")); const { PAYMENT_METHOD_PAYPAL, @@ -103,28 +92,19 @@ interface SettingsProps { | typeof SITE_SETTINGS_SECTION_PAYMENT | typeof SITE_MAILS_HEADER | typeof SITE_CUSTOMISATIONS_SETTING_HEADER - | typeof SITE_APIKEYS_SETTING_HEADER; + | typeof SITE_MISCELLANEOUS_SETTING_HEADER; } const Settings = (props: SettingsProps) => { const [settings, setSettings] = useState>({}); const [newSettings, setNewSettings] = useState>({}); - - type ApiKeyListItem = { - name: string; - keyId: string; - createdAt?: string | number | Date; - }; - - const [apikeyPage, setApikeyPage] = useState(1); - const [apikeys, setApikeys] = useState([]); const [loading, setLoading] = useState(false); const selectedTab = [ SITE_SETTINGS_SECTION_GENERAL, SITE_SETTINGS_SECTION_PAYMENT, SITE_MAILS_HEADER, SITE_CUSTOMISATIONS_SETTING_HEADER, - SITE_APIKEYS_SETTING_HEADER, + SITE_MISCELLANEOUS_SETTING_HEADER, ].includes(props.selectedTab) ? props.selectedTab : SITE_SETTINGS_SECTION_GENERAL; @@ -183,9 +163,6 @@ const Settings = (props: SettingsProps) => { if (response.settings.settings) { setSettingsState(response.settings.settings); } - if (response.apikeys) { - setApikeys(response.apikeys as ApiKeyListItem[]); - } } catch (e) {} }; @@ -512,6 +489,14 @@ const Settings = (props: SettingsProps) => { setNewSettings(Object.assign({}, newSettings, change)); }; + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast({ + title: TOAST_TITLE_SUCCESS, + description: "URL copied to clipboard", + }); + }; + const handlePaymentSettingsSubmit = async ( event: React.FormEvent, ) => { @@ -668,44 +653,14 @@ const Settings = (props: SettingsProps) => { : settings.lemonsqueezySubscriptionYearlyVariantId, }); - const removeApikey = async (keyId: string) => { - const query = ` - mutation { - removed: removeApikey(keyId: "${keyId}") - } - `; - try { - setLoading(true); - const fetchRequest = fetch.setPayload(query).build(); - await fetchRequest.exec(); - setApikeys(apikeys.filter((item) => item.keyId !== keyId)); - } catch (e: any) { - toast({ - title: TOAST_TITLE_ERROR, - description: e.message, - variant: "destructive", - }); - } finally { - setLoading(false); - } - }; - const items = [ SITE_SETTINGS_SECTION_GENERAL, SITE_SETTINGS_SECTION_PAYMENT, SITE_MAILS_HEADER, SITE_CUSTOMISATIONS_SETTING_HEADER, - SITE_APIKEYS_SETTING_HEADER, + SITE_MISCELLANEOUS_SETTING_HEADER, ]; - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - toast({ - title: TOAST_TITLE_SUCCESS, - description: "Webhook URL copied to clipboard", - }); - }; - return (
@@ -1124,7 +1079,7 @@ const Settings = (props: SettingsProps) => { type="submit" value={BUTTON_SAVE} color="primary" - variant="outlined" + variant="outline" disabled={ settings.mailingAddress === newSettings.mailingAddress || loading @@ -1159,7 +1114,7 @@ const Settings = (props: SettingsProps) => { type="submit" value={BUTTON_SAVE} color="primary" - variant="outlined" + variant="outline" disabled={ (settings.codeInjectionHead === newSettings.codeInjectionHead && @@ -1172,76 +1127,7 @@ const Settings = (props: SettingsProps) => {
-
-
-

- {APIKEY_EXISTING_HEADER} -

- - - -
- - - - - - - { - setApikeyPage(value); - }} - > - {apikeys.map( - (item: ApiKeyListItem, index: number) => ( - - - - - - ), - )} - -
{APIKEY_EXISTING_TABLE_HEADER_NAME}{APIKEY_EXISTING_TABLE_HEADER_CREATED} {item.name} - {new Date( - item.createdAt ?? 0, - ).toLocaleDateString()} - - - {APIKEY_REMOVE_BTN} - - } - okButton={ - - } - > - {APIKYE_REMOVE_DIALOG_DESC} - -
- -
+
); diff --git a/apps/web/components/admin/settings/sso.tsx b/apps/web/components/admin/settings/sso.tsx new file mode 100644 index 000000000..1e4ac32e5 --- /dev/null +++ b/apps/web/components/admin/settings/sso.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { Address } from "@courselit/common-models"; +import { useToast } from "@courselit/components-library"; +import { FetchBuilder } from "@courselit/utils"; +import { + SITE_MISCELLANEOUS_SETTING_HEADER, + SSO_PROVIDER_CERT_LABEL, + SSO_PROVIDER_ENTRY_POINT_LABEL, + SSO_PROVIDER_IDP_METADATA_LABEL, + SSO_PROVIDER_HEADER, + SSO_PROVIDER_SUCCESS_MESSAGE, + TOAST_TITLE_ERROR, + TOAST_TITLE_SUCCESS, + PROVIDER_RESET_SUCCESS_MESSAGE, + BTN_RESET, + BUTTON_SAVE, + SSO_PROVIDER_CARD_HEADER, + SSO_PROVIDER_CARD_DESCRIPTION, + SSO_PROVIDER_SP_ACS_LABEL, + SSO_PROVIDER_SP_ENTITY_ID_LABEL, +} from "@ui-config/strings"; +import { useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@components/ui/button"; +import Resources from "@components/resources"; +import { useEffect, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@components/ui/alert-dialog"; +import { Trash2, Loader2, Save, Copy } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@components/ui/card"; +import { Label } from "@components/ui/label"; + +const formSchema = z.object({ + idpMetadata: z.string().min(1, "IDP Metadata is required"), + entryPoint: z.string().min(1, "Entry Point is required"), + cert: z.string().min(1, "Certificate is required"), +}); + +type FormData = z.infer; + +interface NewSSOProviderProps { + address: Address; +} + +export default function SSOProvider({ address }: NewSSOProviderProps) { + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + const [isDeleting, setIsDeleting] = useState(false); + const [isSSOProviderSet, setIsSSOProviderSet] = useState(false); + + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setIsGraphQLEndpoint(true); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + idpMetadata: "", + entryPoint: "", + cert: "", + }, + }); + + useEffect(() => { + const fetchSSOProvider = async () => { + const query = ` + query { + ssoProvider: getSSOProviderSettings { + idpMetadata + entryPoint + cert + } + } + `; + const fetcher = fetch + .setPayload({ + query, + }) + .build(); + try { + const response = await fetcher.exec(); + const { ssoProvider } = response; + if (ssoProvider) { + form.setValue("idpMetadata", ssoProvider.idpMetadata); + form.setValue("entryPoint", ssoProvider.entryPoint); + form.setValue("cert", ssoProvider.cert); + setIsSSOProviderSet(true); + } + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + }; + fetchSSOProvider(); + }, []); + + const updateSSOProvider = async (values: FormData) => { + const query = ` + mutation ( + $idpMetadata: String!, + $entryPoint: String!, + $cert: String!, + $backend: String! + ) { + ssoProvider: updateSSOProvider( + idpMetadata: $idpMetadata, + entryPoint: $entryPoint, + cert: $cert, + backend: $backend, + ) { + providerId + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { + ...values, + backend: address.backend, + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + try { + setLoading(true); + const response = await fetch.exec(); + if (response.ssoProvider) { + toast({ + title: TOAST_TITLE_SUCCESS, + description: SSO_PROVIDER_SUCCESS_MESSAGE, + }); + } else { + toast({ + title: TOAST_TITLE_ERROR, + description: response.error, + variant: "destructive", + }); + } + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const resetProvider = async () => { + const query = ` + mutation { + removeSSOProvider + } + `; + + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + + try { + setIsDeleting(true); + const response = await fetch.exec(); + + if (response.removeSSOProvider) { + toast({ + title: TOAST_TITLE_SUCCESS, + description: PROVIDER_RESET_SUCCESS_MESSAGE, + }); + window.location.href = `/dashboard/settings?tab=${SITE_MISCELLANEOUS_SETTING_HEADER}`; + } + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setIsDeleting(false); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast({ + title: TOAST_TITLE_SUCCESS, + description: "URL copied to clipboard", + }); + }; + + return ( +
+

+ {SSO_PROVIDER_HEADER} +

+
+ + + {SSO_PROVIDER_CARD_HEADER} + + {SSO_PROVIDER_CARD_DESCRIPTION} + + + + +
+ ( + + + {SSO_PROVIDER_ENTRY_POINT_LABEL} + + + + + + + )} + /> + ( + + + { + SSO_PROVIDER_IDP_METADATA_LABEL + } + + +