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`.
+
+ 
+
+3. Click on the Cog icon next to the SSO provider to open SSO configuration.
+
+ 
+
+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:
+
+ 
+
+6. Click on the `Save` button to save the configuration.
+
+ 
+
+7. Go back to the `Login providers` screen and enable the SSO provider.
+
+ 
+
+## 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`
+
+ 
+
+
+
+ - **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.
+
+ 
+
+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
+
+
+
+### 2. Checkout page
+
+
+
+## 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.
+
+
+
+#### 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 && (
-
- )}
- {!showCode && (
-
-
- {LOGIN_FORM_LABEL}
-
-
-
-
- {LOGIN_NO_CODE}
-
+
+
+ {error}
+
+
+
+ )}
+ {!showCode && (
+
+
+ {LOGIN_FORM_LABEL}
+
+
-
+ setEmail(e.target.value)
+ }
theme={theme.theme}
- className="text-xs"
+ />
+
{loading
? LOADING
- : BTN_LOGIN_NO_CODE}
-
-
-
-
-
+ : BTN_LOGIN_GET_CODE}
+
+
+
+ )}
+ {showCode && (
+
+
+ {LOGIN_CODE_INTIMATION_MESSAGE}{" "}
+ {email}
+
+
+
+ setCode(e.target.value)
+ }
+ theme={theme.theme}
+ ref={codeInputRef}
+ />
+
+ {loading ? LOADING : BTN_LOGIN}
+
+ {/* */}
+
+
+
+ {LOGIN_NO_CODE}
+
+
+ {loading
+ ? LOADING
+ : BTN_LOGIN_NO_CODE}
+
+
+
+
+
+ )}
+ >
)}
+ {siteinfo.logins?.includes(
+ Constants.LoginProvider.SSO,
+ ) &&
+ ssoProvider && (
+
{
+ await authClient.signIn.sso({
+ providerId: ssoProvider.providerId,
+ callbackURL: "/dashboard",
+ });
+ }}
+ className="w-full lg:w-[360px] mx-auto"
+ >
+ Login with SSO
+
+ )}
+
+ {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({
-
-
+
{BUTTON_DONE_TEXT}
@@ -122,11 +123,13 @@ export default function NewApikey({
)}
{!apikey && (
-
+
{APIKEY_NEW_BTN_CAPTION}
- {BUTTON_CANCEL_TEXT}
+
+ {BUTTON_CANCEL_TEXT}
+
)}
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}
-
-
- {APIKEY_NEW_BUTTON}
-
-
-
-
- {APIKEY_EXISTING_TABLE_HEADER_NAME}
- {APIKEY_EXISTING_TABLE_HEADER_CREATED}
-
-
- {
- setApikeyPage(value);
- }}
- >
- {apikeys.map(
- (item: ApiKeyListItem, index: number) => (
-
- {item.name}
-
- {new Date(
- item.createdAt ?? 0,
- ).toLocaleDateString()}
-
-
-
- {APIKEY_REMOVE_BTN}
-
- }
- okButton={
-
- removeApikey(
- item.keyId,
- )
- }
- >
- {APIKEY_REMOVE_BTN}
-
- }
- >
- {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
+ }
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ {SSO_PROVIDER_CERT_LABEL}
+
+
+
+
+
+
+ )}
+ />
+
+
+
+ {BUTTON_SAVE}
+
+
+
+
+
+
+
+
+ {isSSOProviderSet && (
+
+ !open && setIsDeleting(false)
+ }
+ >
+
+
+
+ {BTN_RESET}
+
+
+
+
+
+ Clear SSO config?
+
+
+ This action is irreversible. All
+ provider config will be wiped off.
+
+
+
+
+ Cancel
+
+
+ {isDeleting ? (
+ <>
+
+ Resetting...
+ >
+ ) : (
+ "Reset"
+ )}
+
+
+
+
+ )}
+
+
+
+
+ School Settings
+
+ Configuration to be entered in your IDP (Okta, Azure
+ AD, OneLogin etc.)
+
+
+
+ <>
+
+
{SSO_PROVIDER_SP_ACS_LABEL}
+
+
+
+ copyToClipboard(
+ `${address.backend}/api/auth/sso/saml2/sp/acs/sso`,
+ )
+ }
+ >
+
+
+
+
+
+
{SSO_PROVIDER_SP_ENTITY_ID_LABEL}
+
+
+
+ copyToClipboard(
+ `${address.backend}/api/auth/sso/saml2/sp/metadata?providerId=sso`,
+ )
+ }
+ >
+
+
+
+
+ >
+
+
+
+
+ );
+}
diff --git a/apps/web/components/admin/settings/tabs/miscellaneous.tsx b/apps/web/components/admin/settings/tabs/miscellaneous.tsx
new file mode 100644
index 000000000..cdb5334f7
--- /dev/null
+++ b/apps/web/components/admin/settings/tabs/miscellaneous.tsx
@@ -0,0 +1,344 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ CardFooter,
+} from "@components/ui/card";
+import { Checkbox } from "@components/ui/checkbox";
+import { Constants, LoginProvider } from "@courselit/common-models";
+import {
+ ALPHA_LABEL,
+ APIKEY_CARD_DESCRIPTION,
+ APIKEY_EXISTING_HEADER,
+ APIKEY_EXISTING_TABLE_HEADER_CREATED,
+ APIKEY_EXISTING_TABLE_HEADER_NAME,
+ APIKEY_NEW_BUTTON,
+ APIKEY_REMOVE_BTN,
+ APIKEY_REMOVE_DIALOG_DESC,
+ APIKEY_REMOVE_DIALOG_HEADER,
+ LOGIN_METHODS_CARD_DESCRIPTION,
+ LOGIN_METHODS_HEADER,
+ TOAST_TITLE_ERROR,
+} from "@ui-config/strings";
+import { useContext, useEffect, useState } from "react";
+import {
+ AddressContext,
+ FeaturesContext,
+ SiteInfoContext,
+} from "@components/contexts";
+import { capitalize, FetchBuilder } from "@courselit/utils";
+import {
+ Chip,
+ useToast,
+ Table,
+ TableHead,
+ TableBody,
+ TableRow,
+ Dialog2,
+} from "@courselit/components-library";
+import { Button } from "@components/ui/button";
+import { CogIcon, Key, Trash2 } from "lucide-react";
+import Link from "next/link";
+
+type ApiKeyListItem = {
+ name: string;
+ keyId: string;
+ createdAt?: string | number | Date;
+};
+
+export default function MiscellaneousTab() {
+ const [loading, setLoading] = useState(false);
+ const features = useContext(FeaturesContext);
+ const address = useContext(AddressContext);
+ const siteinfo = useContext(SiteInfoContext);
+ const [logins, setLogins] = useState(
+ siteinfo.logins || [],
+ );
+ const [apikeyPage, setApikeyPage] = useState(1);
+ const [apikeys, setApikeys] = useState([]);
+ const { toast } = useToast();
+
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setIsGraphQLEndpoint(true);
+
+ useEffect(() => {
+ const loadApiKeys = async () => {
+ const query = `
+ query {
+ apikeys: getApikeys {
+ name,
+ keyId,
+ createdAt
+ }
+ }
+ `;
+ const fetchRequest = fetch
+ .setPayload({
+ query,
+ })
+ .build();
+ setLoading(true);
+ try {
+ const response = await fetchRequest.exec();
+ if (response.apikeys) {
+ setApikeys(response.apikeys as ApiKeyListItem[]);
+ }
+ } catch (error: any) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: error.message,
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadApiKeys();
+ }, []);
+
+ 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 toggleLoginProvider = async (provider: string, value: boolean) => {
+ const query = `
+ mutation toggleLoginProvider($provider: String!, $value: Boolean!) {
+ providers: toggleLoginProvider(provider: $provider, value: $value)
+ }
+ `;
+ try {
+ const fetchRequest = fetch
+ .setPayload({
+ query,
+ variables: {
+ provider,
+ value,
+ },
+ })
+ .build();
+ setLoading(true);
+ const response = await fetchRequest.exec();
+ if (response.providers) {
+ setLogins(response.providers);
+ }
+ } catch (error) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: error.message,
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {LOGIN_METHODS_HEADER}
+
+ {LOGIN_METHODS_CARD_DESCRIPTION}
+
+
+
+
+ {[
+ Constants.LoginProvider.EMAIL,
+ Constants.LoginProvider.SSO,
+ ].map((provider) => (
+
+
+
{
+ toggleLoginProvider(
+ provider,
+ value,
+ );
+ }}
+ />
+
+
+ {provider ===
+ Constants.LoginProvider.SSO
+ ? provider.toUpperCase()
+ : capitalize(provider)}
+
+ {provider ===
+ Constants.LoginProvider.SSO && (
+ <>
+ {!features.includes(
+ Constants.Features.SSO,
+ ) && Upgrade }
+ {{ALPHA_LABEL} }
+ >
+ )}
+
+
+ {provider !== Constants.LoginProvider.EMAIL && (
+
+
+
+
+
+ )}
+
+ ))}
+
+
+
+
+
+ {APIKEY_EXISTING_HEADER}
+
+ {APIKEY_CARD_DESCRIPTION}.{" "}
+
+ Learn more
+
+ .
+
+
+
+ {apikeys.length === 0 ? (
+
+ ) : (
+
+
+ {APIKEY_EXISTING_TABLE_HEADER_NAME}
+ {APIKEY_EXISTING_TABLE_HEADER_CREATED}
+
+
+ {
+ setApikeyPage(value);
+ }}
+ >
+ {apikeys.map(
+ (item: ApiKeyListItem, index: number) => (
+
+
+ {item.name}
+
+
+ {new Date(
+ item.createdAt ?? 0,
+ ).toLocaleDateString()}
+
+
+
+
+
+ }
+ okButton={
+
+ removeApikey(
+ item.keyId,
+ )
+ }
+ >
+ {APIKEY_REMOVE_BTN}
+
+ }
+ >
+ {APIKEY_REMOVE_DIALOG_DESC}
+
+
+
+ ),
+ )}
+
+
+ )}
+
+
+ {features.includes(Constants.Features.API) ? (
+
+
+ {APIKEY_NEW_BUTTON}
+
+
+ ) : (
+
+ Upgrade
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/components/contexts.tsx b/apps/web/components/contexts.tsx
index 487db6dd3..5f966ba44 100644
--- a/apps/web/components/contexts.tsx
+++ b/apps/web/components/contexts.tsx
@@ -21,4 +21,6 @@ export const ThemeContext = createContext<{
setTheme: any;
}>({ theme: defaultState.theme, setTheme: undefined });
+export const FeaturesContext = createContext(defaultState.features);
+
// export const PageContext = createContext(defaultState.page);
diff --git a/apps/web/components/default-state.ts b/apps/web/components/default-state.ts
index 0d5cfa582..ff514fc8b 100644
--- a/apps/web/components/default-state.ts
+++ b/apps/web/components/default-state.ts
@@ -4,6 +4,8 @@ import {
SiteInfo,
Typeface,
ServerConfig,
+ Features,
+ Constants,
} from "@courselit/common-models";
import { Theme } from "@courselit/page-models";
import { themes } from "@courselit/page-primitives";
@@ -16,6 +18,7 @@ export const defaultState: {
typefaces: Typeface[];
config: ServerConfig;
theme: Theme;
+ features: Features[];
[x: string]: any;
} = {
siteinfo: {
@@ -38,6 +41,7 @@ export const defaultState: {
lemonsqueezyOneTimeVariantId: "",
lemonsqueezySubscriptionMonthlyVariantId: "",
lemonsqueezySubscriptionYearlyVariantId: "",
+ logins: [Constants.LoginProvider.EMAIL],
},
networkAction: false,
profile: {
@@ -74,4 +78,5 @@ export const defaultState: {
name: "",
theme: themes.find((theme) => theme.id === "classic")?.theme!,
},
+ features: [],
};
diff --git a/apps/web/components/public/payments/checkout.tsx b/apps/web/components/public/payments/checkout.tsx
index 7f81939e7..8888ef922 100644
--- a/apps/web/components/public/payments/checkout.tsx
+++ b/apps/web/components/public/payments/checkout.tsx
@@ -36,6 +36,7 @@ import Script from "next/script";
import { Button, Header3, Text1 } from "@courselit/page-primitives";
import { PaymentPlanCard } from "./payment-plan-card";
import { MobileOrderSummary, DesktopOrderSummary } from "./order-summary";
+import { SSOProvider } from "@/app/(with-contexts)/(with-layout)/login/page";
const { PaymentPlanType: paymentPlanType } = Constants;
export interface Product {
@@ -54,6 +55,9 @@ export interface CheckoutScreenProps {
product: Product;
paymentPlans: PaymentPlan[];
includedProducts: Course[];
+ ssoProvider?: SSOProvider;
+ type?: MembershipEntityType;
+ id?: string;
}
const formSchema = z.object({
@@ -65,6 +69,9 @@ export default function Checkout({
product,
paymentPlans,
includedProducts,
+ ssoProvider,
+ type,
+ id,
}: CheckoutScreenProps) {
const siteinfo = useContext(SiteInfoContext);
const { profile } = useContext(ProfileContext);
@@ -397,6 +404,9 @@ export default function Checkout({
onLoginComplete={
handleLoginComplete
}
+ ssoProvider={ssoProvider}
+ type={type}
+ id={id}
/>
) : (
diff --git a/apps/web/components/public/payments/login-form.tsx b/apps/web/components/public/payments/login-form.tsx
index 8e87d6a83..048f688c3 100644
--- a/apps/web/components/public/payments/login-form.tsx
+++ b/apps/web/components/public/payments/login-form.tsx
@@ -1,17 +1,10 @@
"use client";
-import { useContext, useEffect, useState } from "react";
+import { useCallback, useContext, useEffect, useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
-import {
- Button,
- Caption,
- Input,
- Link as PageLink,
- Text1,
- Text2,
-} from "@courselit/page-primitives";
+import { Button, Caption, Input, Text1 } from "@courselit/page-primitives";
import {
FormControl,
FormField,
@@ -21,6 +14,8 @@ import {
import {
AddressContext,
ProfileContext,
+ ServerConfigContext,
+ SiteInfoContext,
ThemeContext,
} from "@components/contexts";
import { useToast } from "@courselit/components-library";
@@ -32,6 +27,10 @@ import {
import { getUserProfile } from "@/app/(with-contexts)/helpers";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
+import { Constants, MembershipEntityType } from "@courselit/common-models";
+import { useRecaptcha } from "@/hooks/use-recaptcha";
+import type { SSOProvider } from "@/app/(with-contexts)/(with-layout)/login/page";
+import RecaptchaScriptLoader from "@components/recaptcha-script-loader";
const loginFormSchema = z.object({
email: z.string().email("Invalid email address"),
@@ -42,15 +41,90 @@ type LoginStep = "email" | "otp" | "complete";
interface LoginFormProps {
onLoginComplete: (email: string, name: string) => void;
+ ssoProvider?: SSOProvider;
+ type?: MembershipEntityType;
+ id?: string;
}
-export function LoginForm({ onLoginComplete }: LoginFormProps) {
+export function LoginForm({
+ onLoginComplete,
+ ssoProvider,
+ type,
+ id,
+}: LoginFormProps) {
const address = useContext(AddressContext);
const { profile, setProfile } = useContext(ProfileContext);
const [loginStep, setLoginStep] = useState
("email");
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const { theme } = useContext(ThemeContext);
+ const siteinfo = useContext(SiteInfoContext);
+ const serverConfig = useContext(ServerConfigContext);
+ const { executeRecaptcha } = useRecaptcha();
+
+ const validateRecaptcha = useCallback(async (): Promise => {
+ if (!serverConfig.recaptchaSiteKey) {
+ return true;
+ }
+
+ if (!executeRecaptcha) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description:
+ "reCAPTCHA service not available. Please try again later.",
+ variant: "destructive",
+ });
+ setLoading(false);
+ return false;
+ }
+
+ const recaptchaToken = await executeRecaptcha("login_code_request");
+ if (!recaptchaToken) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: "reCAPTCHA validation failed. Please try again.",
+ variant: "destructive",
+ });
+ setLoading(false);
+ return false;
+ }
+ try {
+ const recaptchaVerificationResponse = await fetch(
+ "/api/recaptcha",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: recaptchaToken }),
+ },
+ );
+
+ const recaptchaData = await recaptchaVerificationResponse.json();
+
+ if (
+ !recaptchaVerificationResponse.ok ||
+ !recaptchaData.success ||
+ (recaptchaData.score && recaptchaData.score < 0.5)
+ ) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: `reCAPTCHA verification failed. ${recaptchaData.score ? `Score: ${recaptchaData.score.toFixed(2)}.` : ""} Please try again.`,
+ variant: "destructive",
+ });
+ setLoading(false);
+ return false;
+ }
+ } catch (err) {
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: "reCAPTCHA verification failed. Please try again.",
+ variant: "destructive",
+ });
+ setLoading(false);
+ return false;
+ }
+
+ return true;
+ }, []);
useEffect(() => {
if (profile && profile.email) {
@@ -81,8 +155,13 @@ export function LoginForm({ onLoginComplete }: LoginFormProps) {
};
const requestCode = async function (email: string) {
+ setLoading(true);
+
+ if (!validateRecaptcha()) {
+ return;
+ }
+
try {
- setLoading(true);
const { error } = await authClient.emailOtp.sendVerificationOtp({
email: email.trim().toLowerCase(),
type: "sign-in",
@@ -139,116 +218,124 @@ export function LoginForm({ onLoginComplete }: LoginFormProps) {
};
return (
-
-
- {loginStep === "email" && (
- <>
- (
-
-
-
+ {siteinfo.logins?.includes(Constants.LoginProvider.EMAIL) && (
+
+
+ {loginStep === "email" && (
+ <>
+ (
+
+
+
+
+
+
+ )}
+ />
+ {/*
+
-
-
-
- )}
- />
-
-
- {LOGIN_FORM_DISCLAIMER}
-
-
- Terms
-
- {" "}
- and{" "}
-
-
- Privacy Policy
-
-
-
- {/* By signing in, you accept our{" "}
-
- Terms
- {" "}
- and{" "}
-
- Privacy Policy
- */}
-
-
- Continue
-
- >
- )}
+ className="text-center"
+ >
+ {LOGIN_FORM_DISCLAIMER}
+
+
+ Terms
+
+ {" "}
+ and{" "}
+
+
+ Privacy Policy
+
+
+
+ */}
+
+ Continue
+
+ >
+ )}
- {loginStep === "otp" && (
- <>
-
- {LOGIN_CODE_INTIMATION_MESSAGE}
-
- (
-
-
-
-
-
-
- )}
- />
-
- Verify OTP
-
- >
+ {loginStep === "otp" && (
+ <>
+
+ {LOGIN_CODE_INTIMATION_MESSAGE}
+
+ (
+
+
+
+
+
+
+ )}
+ />
+
+ Verify OTP
+
+ >
+ )}
+
+
+ )}
+ {siteinfo.logins?.includes(Constants.LoginProvider.SSO) &&
+ ssoProvider && (
+ {
+ await authClient.signIn.sso({
+ providerId: ssoProvider.providerId,
+ callbackURL: `/checkout?type=${type}&id=${id}`,
+ });
+ }}
+ className="w-full"
+ >
+ Login with SSO
+
)}
-
-
+
+ {LOGIN_FORM_DISCLAIMER}
+
+ Terms
+
+
+
+
);
}
diff --git a/apps/web/config/strings.ts b/apps/web/config/strings.ts
index 8db21757b..fd57db2f7 100644
--- a/apps/web/config/strings.ts
+++ b/apps/web/config/strings.ts
@@ -10,7 +10,7 @@ export const responses = {
domain_super_admin_email_missing:
"SUPER_ADMIN_EMAIL environment variable is not defined",
not_valid_subscription: "No valid subscription found",
- sign_in_mail_prefix: "Sign in to ",
+ sign_in_mail_prefix: "Sign in to",
sign_in_mail_body: "Click the following link to sign in.",
sign_in_link_text: "Sign in",
@@ -137,6 +137,8 @@ export const responses = {
lead_magnet_invalid_settings:
"Product must have exactly one free payment plan to enable lead magnet",
certificate_invalid_settings: "Certificate can only be enabled for courses",
+ sso_provider_already_exists:
+ "A SSO provider with the same provider ID already exists",
// api responses
digital_download_no_files:
@@ -150,6 +152,8 @@ export const responses = {
"Last section cannot be removed from a digital download",
certificate_demo_course_id_required:
"CourseID is required for demo certificate",
+ provider_not_configured: "Configure the provider before enabling",
+ provider_invalid_configuration: "Invalid provider configuration",
};
export const internal = {
diff --git a/apps/web/graphql/settings/__tests__/sso.test.ts b/apps/web/graphql/settings/__tests__/sso.test.ts
new file mode 100644
index 000000000..e31b07d9d
--- /dev/null
+++ b/apps/web/graphql/settings/__tests__/sso.test.ts
@@ -0,0 +1,365 @@
+import {
+ updateSSOProvider,
+ getSSOProviderSettings,
+ getSSOProvider,
+ removeSSOProvider,
+ getFeatures,
+ toggleLoginProvider,
+} from "../logic";
+import DomainModel from "@models/Domain";
+import UserModel from "@models/User";
+import SSOProviderModel from "@models/SSOProvider";
+import constants from "@/config/constants";
+import { Constants } from "@courselit/common-models";
+
+const SUITE_PREFIX = `sso-tests-${Date.now()}`;
+const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`;
+const email = (suffix: string) => `${suffix}-${SUITE_PREFIX}@example.com`;
+
+describe("SSO Logic Tests", () => {
+ let testDomain: any;
+ let adminUser: any;
+ let regularUser: any;
+ let mockCtx: any;
+
+ beforeAll(async () => {
+ // Create test domain with SSO feature enabled
+ testDomain = await DomainModel.create({
+ name: id("domain"),
+ email: email("domain"),
+ features: [Constants.Features.SSO],
+ settings: {
+ logins: [Constants.LoginProvider.EMAIL],
+ },
+ });
+
+ // Create admin user with manageSettings permission
+ adminUser = await UserModel.create({
+ domain: testDomain._id,
+ userId: id("admin"),
+ email: email("admin"),
+ name: "Admin User",
+ permissions: [constants.permissions.manageSettings],
+ active: true,
+ unsubscribeToken: id("unsubscribe-admin"),
+ purchases: [],
+ });
+
+ // Create regular user without permissions
+ regularUser = await UserModel.create({
+ domain: testDomain._id,
+ userId: id("regular"),
+ email: email("regular"),
+ name: "Regular User",
+ permissions: [],
+ active: true,
+ unsubscribeToken: id("unsubscribe-regular"),
+ purchases: [],
+ });
+
+ mockCtx = {
+ user: adminUser,
+ subdomain: testDomain,
+ } as any;
+ });
+
+ afterEach(async () => {
+ await SSOProviderModel.deleteMany({ domain: testDomain._id });
+ // Reset domain settings
+ await DomainModel.updateOne(
+ { _id: testDomain._id },
+ {
+ $set: {
+ "settings.ssoTrustedDomain": undefined,
+ "settings.logins": [Constants.LoginProvider.EMAIL],
+ },
+ },
+ );
+ // Refresh local domain object
+ const updatedDomain = await DomainModel.findById(testDomain._id);
+ mockCtx.subdomain = updatedDomain;
+ });
+
+ afterAll(async () => {
+ await UserModel.deleteMany({ domain: testDomain._id });
+ await DomainModel.deleteOne({ _id: testDomain._id });
+ });
+
+ describe("updateSSOProvider", () => {
+ const validConfig = {
+ idpMetadata: "xml-metadata",
+ entryPoint: "https://idp.example.com",
+ cert: "cert-string",
+ backend: "https://backend.example.com",
+ };
+
+ it("should throw if user is not authenticated", async () => {
+ await expect(
+ updateSSOProvider({ ...validConfig, context: {} as any }),
+ ).rejects.toThrow();
+ });
+
+ it("should throw if user does not have manageSettings permission", async () => {
+ const ctx = { ...mockCtx, user: regularUser };
+ await expect(
+ updateSSOProvider({ ...validConfig, context: ctx }),
+ ).rejects.toThrow();
+ });
+
+ it("should throw if domain does not have SSO feature", async () => {
+ // Temporarily remove SSO feature
+ const noSSODomain = { ...mockCtx.subdomain, features: [] };
+ const ctx = { ...mockCtx, subdomain: noSSODomain };
+ await expect(
+ updateSSOProvider({ ...validConfig, context: ctx }),
+ ).rejects.toThrow();
+ });
+
+ it("should throw if configuration is invalid", async () => {
+ await expect(
+ updateSSOProvider({
+ ...validConfig,
+ idpMetadata: "",
+ context: mockCtx,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("should create SSO provider and update domain settings", async () => {
+ const result = await updateSSOProvider({
+ ...validConfig,
+ context: mockCtx,
+ });
+
+ expect(result).toBeDefined();
+ expect(result.providerId).toBe("sso");
+
+ const savedProvider = await SSOProviderModel.findOne({
+ domain: testDomain._id,
+ });
+ expect(savedProvider).toBeDefined();
+ const samlConfig = JSON.parse(savedProvider!.samlConfig);
+ expect(samlConfig.entryPoint).toBe(validConfig.entryPoint);
+
+ // Check if domain settings updated (refresh context first or check DB)
+ const domain = await DomainModel.findById(testDomain._id);
+ expect(domain!.settings.ssoTrustedDomain).toBe(
+ new URL(validConfig.entryPoint).origin,
+ );
+ });
+ });
+
+ describe("getSSOProviderSettings", () => {
+ it("should return null if no provider exists", async () => {
+ const result = await getSSOProviderSettings(mockCtx);
+ expect(result).toBeNull();
+ });
+
+ it("should return settings if provider exists", async () => {
+ // Setup provider
+ const config = {
+ entryPoint: "https://test-idp.com",
+ cert: "test-cert",
+ idpMetadata: { metadata: "test-metadata" },
+ };
+
+ await SSOProviderModel.create({
+ id: id("sso-1"),
+ domain: testDomain._id,
+ providerId: "sso",
+ samlConfig: JSON.stringify(config),
+ domain_string: "backend.com",
+ });
+
+ const result = await getSSOProviderSettings(mockCtx);
+ expect(result).toEqual({
+ entryPoint: config.entryPoint,
+ cert: config.cert,
+ idpMetadata: config.idpMetadata.metadata,
+ });
+ });
+ });
+
+ describe("getSSOProvider", () => {
+ it("should return null if feature disabled", async () => {
+ const noSSODomain = { ...mockCtx.subdomain, features: [] };
+ const ctx = { ...mockCtx, subdomain: noSSODomain };
+ const result = await getSSOProvider(ctx);
+ expect(result).toBeNull();
+ });
+
+ it("should return null if no provider configured", async () => {
+ const result = await getSSOProvider(mockCtx);
+ expect(result).toBeNull();
+ });
+
+ it("should return provider info if configured", async () => {
+ await SSOProviderModel.create({
+ id: id("sso-2"),
+ domain: testDomain._id,
+ providerId: "sso",
+ samlConfig: "{}",
+ domain_string: "test-domain",
+ });
+
+ const result = await getSSOProvider(mockCtx);
+ expect(result).toEqual({
+ providerId: "sso",
+ domain: "test-domain",
+ });
+ });
+ });
+
+ describe("removeSSOProvider", () => {
+ it("should remove provider and disable SSO login", async () => {
+ // Setup
+ await SSOProviderModel.create({
+ id: id("sso-3"),
+ domain: testDomain._id,
+ providerId: "sso",
+ samlConfig: "{}",
+ domain_string: "test",
+ });
+
+ // Enable SSO login first
+ await toggleLoginProvider({
+ provider: Constants.LoginProvider.SSO,
+ value: true,
+ ctx: mockCtx,
+ });
+
+ const result = await removeSSOProvider(mockCtx);
+ expect(result).toBe(true);
+
+ // Verify removal
+ const provider = await SSOProviderModel.findOne({
+ domain: testDomain._id,
+ });
+ expect(provider).toBeNull();
+
+ // Verify login disabled
+ const domain = await DomainModel.findById(testDomain._id);
+ expect(domain!.settings.logins).not.toContain(
+ Constants.LoginProvider.SSO,
+ );
+ expect(domain!.settings.ssoTrustedDomain).toBeUndefined();
+ });
+ });
+
+ describe("toggleLoginProvider", () => {
+ it("should enable SSO login if provider configured", async () => {
+ // Must have provider first
+ await SSOProviderModel.create({
+ id: id("sso-4"),
+ domain: testDomain._id,
+ providerId: "sso",
+ samlConfig: "{}",
+ domain_string: "test",
+ });
+
+ const result = await toggleLoginProvider({
+ provider: Constants.LoginProvider.SSO,
+ value: true,
+ ctx: mockCtx,
+ });
+
+ expect(result).toContain(Constants.LoginProvider.SSO);
+ });
+
+ it("should throw if enabling SSO without provider", async () => {
+ await expect(
+ toggleLoginProvider({
+ provider: Constants.LoginProvider.SSO,
+ value: true,
+ ctx: mockCtx,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("should toggle email login", async () => {
+ // Ensure we have another provider so we can disable email (though logic.ts might allow disabling if it's not the ONLY one, or logic prevents disabling the last one)
+ // logic.ts: if !value and logins.length <= 1 and contains EMAIL -> throw.
+ // So we cannot disable email if it is the only one.
+
+ await expect(
+ toggleLoginProvider({
+ provider: Constants.LoginProvider.EMAIL,
+ value: false,
+ ctx: mockCtx,
+ }),
+ ).rejects.toThrow();
+
+ // Add SSO then disable email
+ await SSOProviderModel.create({
+ id: id("sso-5"),
+ domain: testDomain._id,
+ providerId: "sso",
+ samlConfig: "{}",
+ domain_string: "test",
+ });
+ await toggleLoginProvider({
+ provider: Constants.LoginProvider.SSO,
+ value: true,
+ ctx: mockCtx,
+ });
+
+ // Now disable email
+ const result = await toggleLoginProvider({
+ provider: Constants.LoginProvider.EMAIL,
+ value: false,
+ ctx: mockCtx,
+ });
+ expect(result).not.toContain(Constants.LoginProvider.EMAIL);
+ });
+
+ it("should automatically re-enable email if SSO is disabled and it was the only provider", async () => {
+ // Setup: Create provider and enable SSO
+ await SSOProviderModel.create({
+ id: id("sso-auto-enable"),
+ domain: testDomain._id,
+ providerId: "sso",
+ samlConfig: "{}",
+ domain_string: "test",
+ });
+
+ await toggleLoginProvider({
+ provider: Constants.LoginProvider.SSO,
+ value: true,
+ ctx: mockCtx,
+ });
+
+ // Disable Email (allowed because SSO is enabled)
+ await toggleLoginProvider({
+ provider: Constants.LoginProvider.EMAIL,
+ value: false,
+ ctx: mockCtx,
+ });
+
+ expect(mockCtx.subdomain.settings.logins).not.toContain(
+ Constants.LoginProvider.EMAIL,
+ );
+ expect(mockCtx.subdomain.settings.logins).toContain(
+ Constants.LoginProvider.SSO,
+ );
+
+ // Disable SSO - should fallback to Email
+ const result = await toggleLoginProvider({
+ provider: Constants.LoginProvider.SSO,
+ value: false,
+ ctx: mockCtx,
+ });
+
+ expect(result).toContain(Constants.LoginProvider.EMAIL);
+ expect(mockCtx.subdomain.settings.logins).toContain(
+ Constants.LoginProvider.EMAIL,
+ );
+ });
+ });
+
+ describe("getFeatures", () => {
+ it("should return domain features", async () => {
+ const features = await getFeatures(mockCtx);
+ expect(features).toContain(Constants.Features.SSO);
+ });
+ });
+});
diff --git a/apps/web/graphql/settings/helpers.ts b/apps/web/graphql/settings/helpers.ts
index d3ff38b22..389021579 100644
--- a/apps/web/graphql/settings/helpers.ts
+++ b/apps/web/graphql/settings/helpers.ts
@@ -3,10 +3,12 @@ import { responses } from "../../config/strings";
import currencies from "@/data/currencies.json";
import {
Constants,
+ LoginProvider,
PaymentMethod,
SiteInfo,
UIConstants,
} from "@courselit/common-models";
+import GQLContext from "@models/GQLContext";
const currencyISOCodes = currencies.map((currency) =>
currency.isoCode?.toLowerCase(),
@@ -105,3 +107,26 @@ export const getPaymentInvalidException = (paymentMethod: string) =>
responses.payment_settings_invalid_suffix
}`,
);
+
+export async function saveLoginProvider({
+ ctx,
+ value,
+ provider,
+}: {
+ ctx: GQLContext;
+ value: boolean;
+ provider: LoginProvider;
+}) {
+ const loginsSet = new Set(ctx.subdomain.settings.logins || []);
+ if (value) {
+ loginsSet.add(provider);
+ } else {
+ loginsSet.delete(provider);
+ }
+ const logins = Array.from(loginsSet);
+ if (!logins.length) {
+ logins.push(Constants.LoginProvider.EMAIL);
+ }
+ ctx.subdomain.settings.logins = logins;
+ await (ctx.subdomain as any).save();
+}
diff --git a/apps/web/graphql/settings/logic.ts b/apps/web/graphql/settings/logic.ts
index 63f27a9f8..6029f7f2b 100644
--- a/apps/web/graphql/settings/logic.ts
+++ b/apps/web/graphql/settings/logic.ts
@@ -5,12 +5,14 @@ import {
checkForInvalidPaymentSettings,
checkForInvalidPaymentMethodSettings,
getPaymentInvalidException,
+ saveLoginProvider,
} from "./helpers";
import type GQLContext from "../../models/GQLContext";
import DomainModel, { Domain } from "../../models/Domain";
import { checkPermission } from "@courselit/utils";
-import { Typeface } from "@courselit/common-models";
+import { Constants, LoginProvider, Typeface } from "@courselit/common-models";
import ApikeyModel, { ApiKey } from "@models/ApiKey";
+import SSOProviderModel from "@models/SSOProvider";
const { permissions } = constants;
@@ -40,21 +42,6 @@ export const getSiteInfo = async (ctx: GQLContext) => {
return domain;
};
-/*
-export const getSiteInfoAsAdmin = async (ctx: GQLContext) => {
- checkIfAuthenticated(ctx);
-
- if (!checkPermission(ctx.user.permissions, [permissions.manageSettings])) {
- throw new Error(responses.action_not_allowed);
- }
-
- const siteinfo: SiteInfo | null = await SiteInfoModel.findOne({
- domain: ctx.subdomain._id,
- });
- return siteinfo;
-};
-*/
-
export const updateSiteInfo = async (
siteData: Record,
ctx: GQLContext,
@@ -168,6 +155,10 @@ export const updatePaymentInfo = async (
};
export const getApikeys = async (ctx: GQLContext) => {
+ if (!ctx.subdomain.features?.includes(Constants.Features.API)) {
+ return [];
+ }
+
checkIfAuthenticated(ctx);
if (!checkPermission(ctx.user.permissions, [permissions.manageSettings])) {
@@ -193,6 +184,10 @@ export const addApikey = async (name: string, ctx: GQLContext) => {
throw new Error(responses.action_not_allowed);
}
+ if (!ctx.subdomain.features?.includes(Constants.Features.API)) {
+ throw new Error(responses.action_not_allowed);
+ }
+
const existingApikey = await ApikeyModel.findOne({
name,
domain: ctx.subdomain._id,
@@ -221,7 +216,220 @@ export const removeApikey = async (keyId: string, ctx: GQLContext) => {
throw new Error(responses.action_not_allowed);
}
+ if (!ctx.subdomain.features?.includes(Constants.Features.API)) {
+ throw new Error(responses.action_not_allowed);
+ }
+
await ApikeyModel.deleteOne({ keyId, domain: ctx.subdomain._id });
return true;
};
+
+export const updateSSOProvider = async ({
+ idpMetadata,
+ entryPoint,
+ cert,
+ backend,
+ context: ctx,
+}: {
+ idpMetadata: string;
+ entryPoint: string;
+ cert: string;
+ backend: string;
+ context: GQLContext;
+}) => {
+ checkIfAuthenticated(ctx);
+
+ if (!checkPermission(ctx.user.permissions, [permissions.manageSettings])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ if (!ctx.subdomain.features?.includes(Constants.Features.SSO)) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ if (!idpMetadata || !entryPoint || !cert || !backend) {
+ throw new Error(responses.provider_invalid_configuration);
+ }
+
+ const backendUrl = new URL(backend);
+
+ try {
+ const ssoProvider = await SSOProviderModel.findOneAndUpdate(
+ {
+ domain: ctx.subdomain._id,
+ },
+ {
+ providerId: "sso",
+ samlConfig: JSON.stringify({
+ entryPoint,
+ cert,
+ callbackUrl: `${backendUrl.origin}/api/auth/sso/saml2/callback/sso`,
+ idpMetadata: {
+ metadata: idpMetadata,
+ },
+ spMetadata: {},
+ }),
+ domain_string: backendUrl.hostname,
+ domain: ctx.subdomain._id,
+ },
+ {
+ upsert: true,
+ new: true,
+ },
+ );
+
+ ctx.subdomain.settings.ssoTrustedDomain = new URL(entryPoint).origin;
+ (ctx.subdomain as any).markModified("settings");
+ await (ctx.subdomain as any).save();
+
+ return ssoProvider;
+ } catch (error: any) {
+ throw error;
+ }
+};
+
+export const getSSOProviderSettings = async (ctx: GQLContext) => {
+ checkIfAuthenticated(ctx);
+
+ if (!checkPermission(ctx.user.permissions, [permissions.manageSettings])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ if (!ctx.subdomain.features?.includes(Constants.Features.SSO)) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ const ssoProvider = await SSOProviderModel.findOne({
+ domain: ctx.subdomain._id,
+ });
+
+ if (!ssoProvider) {
+ return null;
+ }
+
+ const samlConfig = JSON.parse(ssoProvider?.samlConfig || "{}");
+
+ return {
+ idpMetadata: samlConfig?.idpMetadata?.metadata,
+ entryPoint: samlConfig?.entryPoint,
+ cert: samlConfig?.cert,
+ };
+};
+
+export const getSSOProvider = async (ctx: GQLContext) => {
+ if (!ctx.subdomain.features?.includes(Constants.Features.SSO)) {
+ return null;
+ }
+
+ const ssoProvider = await SSOProviderModel.findOne(
+ {
+ domain: ctx.subdomain._id,
+ },
+ {
+ providerId: 1,
+ domain_string: 1,
+ },
+ );
+
+ if (!ssoProvider) {
+ return null;
+ }
+
+ return {
+ providerId: ssoProvider.providerId,
+ domain: ssoProvider.domain_string,
+ };
+};
+
+export const removeSSOProvider = async (ctx: GQLContext) => {
+ checkIfAuthenticated(ctx);
+
+ if (!checkPermission(ctx.user.permissions, [permissions.manageSettings])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ if (!ctx.subdomain.features?.includes(Constants.Features.SSO)) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ await SSOProviderModel.deleteMany({ domain: ctx.subdomain._id });
+
+ await toggleLoginProvider({
+ provider: Constants.LoginProvider.SSO,
+ value: false,
+ ctx,
+ });
+
+ ctx.subdomain.settings.ssoTrustedDomain = undefined;
+ (ctx.subdomain as any).markModified("settings");
+ await (ctx.subdomain as any).save();
+
+ return true;
+};
+
+export const getFeatures = async (ctx: GQLContext) => {
+ await DomainModel.findOne({
+ _id: ctx.subdomain._id,
+ });
+
+ return ctx.subdomain.features || [];
+};
+
+export const toggleLoginProvider = async ({
+ provider,
+ value,
+ ctx,
+}: {
+ provider: LoginProvider;
+ value: boolean;
+ ctx: GQLContext;
+}) => {
+ checkIfAuthenticated(ctx);
+
+ if (!checkPermission(ctx.user.permissions, [permissions.manageSettings])) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ switch (provider) {
+ case Constants.LoginProvider.EMAIL:
+ if (
+ !value &&
+ (ctx.subdomain.settings.logins?.length === 0 ||
+ (ctx.subdomain.settings.logins?.length === 1 &&
+ ctx.subdomain.settings.logins.includes(
+ Constants.LoginProvider.EMAIL,
+ )))
+ ) {
+ throw new Error(responses.action_not_allowed);
+ }
+ await saveLoginProvider({
+ ctx,
+ value,
+ provider: Constants.LoginProvider.EMAIL,
+ });
+ break;
+ case Constants.LoginProvider.SSO:
+ if (value) {
+ if (!ctx.subdomain.features?.includes(Constants.Features.SSO)) {
+ throw new Error(responses.action_not_allowed);
+ }
+
+ const ssoProviders = await SSOProviderModel.find({
+ domain: ctx.subdomain._id,
+ });
+
+ if (ssoProviders.length === 0) {
+ throw new Error(responses.provider_not_configured);
+ }
+ }
+ await saveLoginProvider({
+ ctx,
+ value,
+ provider: Constants.LoginProvider.SSO,
+ });
+ break;
+ }
+
+ return ctx.subdomain.settings.logins || [Constants.LoginProvider.EMAIL];
+};
diff --git a/apps/web/graphql/settings/mutation.ts b/apps/web/graphql/settings/mutation.ts
index 3c997d08e..b6cbc1269 100644
--- a/apps/web/graphql/settings/mutation.ts
+++ b/apps/web/graphql/settings/mutation.ts
@@ -11,10 +11,11 @@ import {
updateDraftTypefaces,
removeApikey,
addApikey,
- // updateDraftTheme,
+ removeSSOProvider,
+ toggleLoginProvider,
+ updateSSOProvider,
} from "./logic";
-import { Typeface } from "@courselit/common-models";
-// import { GraphQLJSONObject } from "graphql-type-json";
+import { LoginProvider, Typeface } from "@courselit/common-models";
const mutations = {
updateSiteInfo: {
@@ -72,37 +73,60 @@ const mutations = {
resolve: async (_: any, { keyId }: { keyId: string }, context: any) =>
removeApikey(keyId, context),
},
- // updateDraftTheme: {
- // type: types.domain,
- // args: {
- // colors: { type: GraphQLJSONObject },
- // typography: { type: GraphQLJSONObject },
- // interactives: { type: GraphQLJSONObject },
- // structure: { type: GraphQLJSONObject },
- // },
- // resolve: async (
- // _: any,
- // {
- // colors,
- // typography,
- // interactives,
- // structure,
- // }: {
- // colors?: Theme["colors"];
- // typography?: Theme["typography"];
- // interactives?: Theme["interactives"];
- // structure?: Theme["structure"];
- // },
- // context: any,
- // ) =>
- // updateDraftTheme(
- // context,
- // colors,
- // typography,
- // interactives,
- // structure,
- // ),
- // },
+ updateSSOProvider: {
+ type: types.ssoProviderType,
+ args: {
+ idpMetadata: { type: new GraphQLNonNull(GraphQLString) },
+ entryPoint: { type: new GraphQLNonNull(GraphQLString) },
+ cert: { type: new GraphQLNonNull(GraphQLString) },
+ backend: { type: new GraphQLNonNull(GraphQLString) },
+ },
+ resolve: async (
+ _: any,
+ {
+ idpMetadata,
+ entryPoint,
+ cert,
+ backend,
+ }: {
+ idpMetadata: string;
+ entryPoint: string;
+ cert: string;
+ backend: string;
+ },
+ context: any,
+ ) =>
+ updateSSOProvider({
+ idpMetadata,
+ entryPoint,
+ cert,
+ backend,
+ context,
+ }),
+ },
+ removeSSOProvider: {
+ type: new GraphQLNonNull(GraphQLBoolean),
+ resolve: async (_: any, __: any, context: any) =>
+ removeSSOProvider(context),
+ },
+ toggleLoginProvider: {
+ type: new GraphQLNonNull(new GraphQLList(GraphQLString)),
+ args: {
+ provider: { type: new GraphQLNonNull(GraphQLString) },
+ value: { type: new GraphQLNonNull(GraphQLBoolean) },
+ },
+ resolve: async (
+ _: any,
+ {
+ provider,
+ value,
+ }: {
+ provider: LoginProvider;
+ value: boolean;
+ },
+ context: any,
+ ) => toggleLoginProvider({ provider, value, ctx: context }),
+ },
};
export default mutations;
diff --git a/apps/web/graphql/settings/query.ts b/apps/web/graphql/settings/query.ts
index abc0fbb4c..eaac353c8 100644
--- a/apps/web/graphql/settings/query.ts
+++ b/apps/web/graphql/settings/query.ts
@@ -1,6 +1,13 @@
import types from "./types";
-import { getApikeys, getSiteInfo } from "./logic";
-import { GraphQLList } from "graphql";
+import {
+ getApikeys,
+ getFeatures,
+ // getLoginProviders,
+ getSiteInfo,
+ getSSOProvider,
+ getSSOProviderSettings,
+} from "./logic";
+import { GraphQLList, GraphQLString } from "graphql";
import GQLContext from "@models/GQLContext";
const queries = {
@@ -13,6 +20,27 @@ const queries = {
args: {},
resolve: (_: any, {}: any, context: GQLContext) => getApikeys(context),
},
+ getSSOProvider: {
+ type: types.ssoProviderType,
+ resolve: (_: any, {}: any, context: GQLContext) =>
+ getSSOProvider(context),
+ },
+ getSSOProviderSettings: {
+ type: types.ssoProviderSettingsType,
+ resolve: (_: any, {}: any, context: GQLContext) =>
+ getSSOProviderSettings(context),
+ },
+ getFeatures: {
+ type: new GraphQLList(GraphQLString),
+ args: {},
+ resolve: (_: any, {}: any, context: GQLContext) => getFeatures(context),
+ },
+ // getLoginProviders: {
+ // type: new GraphQLList(GraphQLString),
+ // args: {},
+ // resolve: (_: any, { }: any, context: GQLContext) =>
+ // getLoginProviders(context),
+ // },
};
export default queries;
diff --git a/apps/web/graphql/settings/types.ts b/apps/web/graphql/settings/types.ts
index 65529311f..b65e28f24 100644
--- a/apps/web/graphql/settings/types.ts
+++ b/apps/web/graphql/settings/types.ts
@@ -59,6 +59,7 @@ const siteType = new GraphQLObjectType({
codeInjectionBody: { type: GraphQLString },
mailingAddress: { type: GraphQLString },
hideCourseLitBranding: { type: GraphQLBoolean },
+ logins: { type: new GraphQLList(GraphQLString) },
},
});
@@ -153,6 +154,23 @@ const apikeyUpdateInput = new GraphQLInputObjectType({
},
});
+const ssoProviderType = new GraphQLObjectType({
+ name: "SSOProvider",
+ fields: {
+ providerId: { type: new GraphQLNonNull(GraphQLString) },
+ domain: { type: new GraphQLNonNull(GraphQLString) },
+ },
+});
+
+const ssoProviderSettingsType = new GraphQLObjectType({
+ name: "SSOProviderSettings",
+ fields: {
+ idpMetadata: { type: new GraphQLNonNull(GraphQLString) },
+ entryPoint: { type: new GraphQLNonNull(GraphQLString) },
+ cert: { type: new GraphQLNonNull(GraphQLString) },
+ },
+});
+
const types = {
siteUpdateType,
sitePaymentUpdateType,
@@ -161,6 +179,8 @@ const types = {
apikeyType,
apikeyUpdateInput,
newApikeyType,
+ ssoProviderType,
+ ssoProviderSettingsType,
};
export default types;
diff --git a/apps/web/graphql/users/helpers.ts b/apps/web/graphql/users/helpers.ts
index 5575cf5b5..c7b99362a 100644
--- a/apps/web/graphql/users/helpers.ts
+++ b/apps/web/graphql/users/helpers.ts
@@ -37,6 +37,7 @@ import {
import CommunityPostModel from "@models/CommunityPost";
import CommunityCommentModel from "@models/CommunityComment";
import { deleteMedia } from "@/services/medialit";
+import Account from "@models/Account";
const { permissions } = UIConstants;
@@ -360,9 +361,17 @@ export async function cleanupPersonalData(
{ $pull: { customers: userToDelete.userId } },
);
+ await Account.deleteOne({
+ domain: ctx.subdomain._id,
+ userId: userToDelete._id,
+ });
+
if (userToDelete.avatar?.mediaId) {
await deleteMedia(userToDelete.avatar.mediaId);
}
- await UserModel.deleteOne({ _id: userToDelete._id });
+ await UserModel.deleteOne({
+ domain: ctx.subdomain._id,
+ _id: userToDelete._id,
+ });
}
diff --git a/apps/web/jest.server.config.ts b/apps/web/jest.server.config.ts
index d7c9f04a2..8218f5b8d 100644
--- a/apps/web/jest.server.config.ts
+++ b/apps/web/jest.server.config.ts
@@ -18,6 +18,7 @@ const config: Config = {
"@/payments-new": "/payments-new",
"@/graphql/(.*)": "/graphql/$1",
"@/config/(.*)": "/config/$1",
+ "@/data/(.*)": "/data/$1",
"@/lib/(.*)": "/lib/$1",
"@/services/(.*)": "/services/$1",
"@/templates/(.*)": "/templates/$1",
diff --git a/apps/web/lib/auth-client.ts b/apps/web/lib/auth-client.ts
index 73e15bf7c..1cca6d52e 100644
--- a/apps/web/lib/auth-client.ts
+++ b/apps/web/lib/auth-client.ts
@@ -1,6 +1,7 @@
import { createAuthClient } from "better-auth/client";
import { emailOTPClient } from "better-auth/client/plugins";
+import { ssoClient } from "@better-auth/sso/client";
export const authClient = createAuthClient({
- plugins: [emailOTPClient()],
+ plugins: [emailOTPClient(), ssoClient()],
});
diff --git a/apps/web/models/Account.ts b/apps/web/models/Account.ts
new file mode 100644
index 000000000..74f16f5db
--- /dev/null
+++ b/apps/web/models/Account.ts
@@ -0,0 +1,23 @@
+import mongoose from "mongoose";
+
+const AccountSchema = new mongoose.Schema(
+ {
+ domain: { type: mongoose.Schema.Types.ObjectId, required: true },
+ userId: { type: mongoose.Schema.Types.ObjectId, required: true },
+ accountId: { type: String, required: true },
+ providerId: { type: String, required: true },
+ accessToken: String,
+ refreshToken: String,
+ accessTokenExpiresAt: Date,
+ refreshTokenExpiresAt: Date,
+ scope: String,
+ idToken: String,
+ password: String,
+ },
+ {
+ timestamps: true,
+ },
+);
+
+export default mongoose.models.Account ||
+ mongoose.model("Account", AccountSchema);
diff --git a/apps/web/models/Domain.ts b/apps/web/models/Domain.ts
index 6a2558131..5c190f49f 100644
--- a/apps/web/models/Domain.ts
+++ b/apps/web/models/Domain.ts
@@ -2,12 +2,18 @@ import mongoose from "mongoose";
import SettingsSchema from "./SiteInfo";
import TypefaceSchema from "./Typeface";
import constants from "../config/constants";
-import { Domain as PublicDomain, Typeface } from "@courselit/common-models";
+import {
+ Constants,
+ Features,
+ Domain as PublicDomain,
+ Typeface,
+} from "@courselit/common-models";
const { typeface } = constants;
export interface Domain extends PublicDomain {
_id: mongoose.Types.ObjectId;
lastEditedThemeId?: string;
+ features?: Features[];
}
export const defaultTypeface: Typeface = {
@@ -58,6 +64,11 @@ const DomainSchema = new mongoose.Schema(
lastMonthlyCountUpdate: { type: Date, default: Date.now },
}),
}),
+ features: {
+ type: [String],
+ enum: Object.values(Constants.Features),
+ default: [],
+ },
},
{
timestamps: true,
diff --git a/apps/web/models/SSOProvider.ts b/apps/web/models/SSOProvider.ts
new file mode 100644
index 000000000..01fb25f82
--- /dev/null
+++ b/apps/web/models/SSOProvider.ts
@@ -0,0 +1,22 @@
+import mongoose from "mongoose";
+
+const SSOProviderSchema = new mongoose.Schema(
+ {
+ domain: { type: mongoose.Schema.Types.ObjectId, required: true },
+ providerId: { type: String, required: true },
+ issuer: { type: String },
+ domain_string: { type: String },
+ oidcConfig: { type: String },
+ samlConfig: { type: String },
+ userId: { type: String },
+ organizationId: { type: String },
+ },
+ {
+ collection: "ssoProviders",
+ },
+);
+
+SSOProviderSchema.index({ domain: 1, providerId: 1 }, { unique: true });
+
+export default mongoose.models.SSOProvider ||
+ mongoose.model("SSOProvider", SSOProviderSchema);
diff --git a/apps/web/models/SiteInfo.ts b/apps/web/models/SiteInfo.ts
index d716b3ad6..6e83b465f 100644
--- a/apps/web/models/SiteInfo.ts
+++ b/apps/web/models/SiteInfo.ts
@@ -25,6 +25,8 @@ const SettingsSchema = new mongoose.Schema({
lemonsqueezyOneTimeVariantId: { type: String },
lemonsqueezySubscriptionMonthlyVariantId: { type: String },
lemonsqueezySubscriptionYearlyVariantId: { type: String },
+ logins: { type: [String], enum: Object.values(Constants.LoginProvider) },
+ ssoTrustedDomain: { type: String },
});
export default SettingsSchema;
diff --git a/apps/web/package.json b/apps/web/package.json
index cecd826f5..5a66a4e62 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,6 +9,7 @@
"prettier": "prettier --write **/*.ts"
},
"dependencies": {
+ "@better-auth/sso": "^1.4.6",
"@courselit/common-logic": "workspace:^",
"@courselit/common-models": "workspace:^",
"@courselit/components-library": "workspace:^",
@@ -110,4 +111,4 @@
"@types/react": "19.2.4"
}
}
-}
+}
\ No newline at end of file
diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts
index d65ef924c..a76893611 100644
--- a/apps/web/proxy.ts
+++ b/apps/web/proxy.ts
@@ -27,6 +27,9 @@ export async function proxy(request: NextRequest) {
"hideCourseLitBranding",
resp.hideCourseLitBranding || false,
);
+ if (resp.ssoTrustedDomain) {
+ requestHeaders.set("ssoTrustedDomain", resp.ssoTrustedDomain);
+ }
if (request.nextUrl.pathname === "/favicon.ico") {
try {
diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts
index 46f3490c7..8f48119f1 100644
--- a/apps/web/ui-config/strings.ts
+++ b/apps/web/ui-config/strings.ts
@@ -106,6 +106,7 @@ export const SITE_ADMIN_SETTINGS_PAYPAL_SECRET = "Paypal Secret Key";
export const SITE_ADMIN_SETTINGS_PAYTM_SECRET = "Paytm Secret Key";
export const SITE_SETTINGS_SECTION_GENERAL = "Branding";
export const SITE_SETTINGS_SECTION_PAYMENT = "Payment";
+export const SITE_SETTINGS_SECTION_MAILS = "Mails";
export const SITE_ADMIN_SETTINGS_PAYMENT_METHOD = "Payment Method";
export const SITE_SETTINGS_STRIPE_PUBLISHABLE_KEY_TEXT =
"Stripe Publishable Key";
@@ -224,6 +225,9 @@ export const MAIL_REQUEST_RECEIVED =
export const MAIL_REQUEST_FORM_SUBMIT_INITIAL_REQUEST_TEXT = "Request access";
export const MAIL_REQUEST_FORM_SUBMIT_UPDATE_REQUEST_TEXT = "Update reason";
export const SITE_CUSTOMISATIONS_SETTING_HEADER = "Code Injection";
+export const SITE_MISCELLANEOUS_SETTING_HEADER = "Miscellaneous";
+export const ALPHA_LABEL = "Alpha";
+export const BETA_LABEL = "Beta";
export const SITE_CUSTOMISATIONS_SETTING_CODEINJECTION_HEAD =
"Code Injection in ";
export const SITE_CUSTOMISATIONS_SETTING_CODEINJECTION_BODY =
@@ -566,19 +570,48 @@ export const LESSON_GROUP_DELETED = "Section deleted";
export const USER_PERMISSION_AREA_SUBTEXT =
"Control what actions this user can perform in your school.";
export const APIKEY_NEW_BUTTON = "New API key";
-export const APIKEY_EXISTING_HEADER = "Your API keys";
+export const ADD_SSO_PROVIDER_BUTTON = "New provider";
+export const APIKEY_EXISTING_HEADER = "API keys";
+export const APIKEY_CARD_DESCRIPTION =
+ "Using API keys you can interact with CourseLit API and build custom integrations";
+export const SSO_CARD_DESCRIPTION =
+ "Let your existing users sign into the your school";
+export const SINGLE_SIGN_ON_HEADER = "Single sign-on (SSO)";
+export const LOGIN_METHODS_HEADER = "Login providers";
+export const LOGIN_METHODS_CARD_DESCRIPTION =
+ "Control how your users access the school";
export const APIKEY_EXISTING_TABLE_HEADER_CREATED = "Created";
export const APIKEY_EXISTING_TABLE_HEADER_NAME = "Name";
export const APIKEY_NEW_HEADER = "New API key";
+export const SSO_PROVIDER_HEADER = "SSO Provider";
+export const SSO_PROVIDER_CARD_HEADER = "IDP Configuration";
+export const SSO_PROVIDER_CARD_DESCRIPTION =
+ "Enter the values from your IDP (Okta, Azure AD, OneLogin, etc.)";
+export const SSO_PROVIDER_SP_EMTPY =
+ "Enter your IDP settings to see these settings";
+export const SSO_PROVIDER_SP_ACS_LABEL = "SAML ACS URL";
+export const SSO_PROVIDER_SP_ENTITY_ID_LABEL = "Audience URI (SP Entity ID)";
export const APIKEY_NEW_LABEL = "Name";
+export const SSO_PROVIDER_DOMAIN_LABEL = "Domain";
+export const SSO_PROVIDER_ENTRY_POINT_LABEL = "Entry point";
+export const SSO_PROVIDER_CERT_LABEL = "Certificate";
+export const SSO_PROVIDER_CALLBACK_URL_LABEL = "Callback URL";
+export const SSO_PROVIDER_IDP_METADATA_LABEL = "IDP Metadata";
+export const SSO_PROVIDER_PROVIDER_ID_LABEL = "Provider ID";
+export const SSO_PROVIDER_SUCCESS_MESSAGE = "SSO provider added successfully";
+export const PROVIDER_RESET_SUCCESS_MESSAGE = "Provider reset successfully";
export const APIKEY_NEW_BTN_CAPTION = "Create";
export const APIKEY_NEW_GENERATED_KEY_HEADER = "Your new API key";
export const APIKEY_NEW_GENERATED_KEY_DESC =
"Please copy it and store it securely. You won't be able to see it again.";
export const APIKEY_NEW_GENERATED_KEY_COPIED = "Copied to clipboard";
export const APIKEY_REMOVE_BTN = "Remove";
+export const SSO_PROVIDER_REMOVE_BTN = "Remove";
export const APIKEY_REMOVE_DIALOG_HEADER = "Remove API Key";
-export const APIKYE_REMOVE_DIALOG_DESC =
+export const SSO_PROVIDER_REMOVE_DIALOG_HEADER = "Remove SSO Provider";
+export const SSO_PROVIDER_REMOVE_DIALOG_DESC =
+ "Are you sure you want to remove this provider?";
+export const APIKEY_REMOVE_DIALOG_DESC =
"If you are using this key in your application, removing it will break the integration. There is no going back if you remove it.";
export const USER_TAGS_SUBHEADER = "Tags";
export const BTN_DELETE_USER = "Delete user";
diff --git a/apps/web/ui-lib/utils.ts b/apps/web/ui-lib/utils.ts
index 74955e58e..4306621cf 100644
--- a/apps/web/ui-lib/utils.ts
+++ b/apps/web/ui-lib/utils.ts
@@ -2,6 +2,7 @@ import type {
CommunityMemberStatus,
CommunityReportStatus,
Course,
+ Features,
Group,
Membership,
MembershipRole,
@@ -150,6 +151,7 @@ export const getSiteInfo = async (
lemonsqueezyOneTimeVariantId,
lemonsqueezySubscriptionMonthlyVariantId,
lemonsqueezySubscriptionYearlyVariantId,
+ logins,
},
}
}
@@ -175,6 +177,7 @@ export const getFullSiteSetup = async (
settings: SiteInfo;
theme: Theme;
page: FrontEndPage;
+ features: Features[];
}
| undefined
> => {
@@ -204,6 +207,7 @@ export const getFullSiteSetup = async (
},
robotsAllowed,
}
+ features: getFeatures
}
`;
const fetch = new FetchBuilder()
@@ -228,6 +232,7 @@ export const getFullSiteSetup = async (
settings,
theme: transformedTheme,
page: response.page,
+ features: response.features,
};
} catch (e: any) {
console.log("getSiteInfo", e.message); // eslint-disable-line no-console
diff --git a/packages/common-models/src/constants.ts b/packages/common-models/src/constants.ts
index fd92a8138..0e75b5329 100644
--- a/packages/common-models/src/constants.ts
+++ b/packages/common-models/src/constants.ts
@@ -181,3 +181,18 @@ export const EmailEventAction = {
CLICK: "click",
BOUNCE: "bounce",
} as const;
+export const LoginProvider = {
+ GOOGLE: "google",
+ FACEBOOK: "facebook",
+ GITHUB: "github",
+ LINKEDIN: "linkedin",
+ TWITTER: "twitter",
+ APPLE: "apple",
+ EMAIL: "email",
+ SSO: "sso",
+} as const;
+export const Features = {
+ SSO: "sso",
+ API: "api",
+ LOG: "log",
+} as const;
diff --git a/packages/common-models/src/features.ts b/packages/common-models/src/features.ts
new file mode 100644
index 000000000..09b70d2ff
--- /dev/null
+++ b/packages/common-models/src/features.ts
@@ -0,0 +1,4 @@
+import { Constants } from ".";
+
+export type Features =
+ (typeof Constants.Features)[keyof typeof Constants.Features];
diff --git a/packages/common-models/src/index.ts b/packages/common-models/src/index.ts
index ff62e5839..e48836486 100644
--- a/packages/common-models/src/index.ts
+++ b/packages/common-models/src/index.ts
@@ -68,3 +68,5 @@ export * from "./notification";
export * from "./course";
export * from "./activity-type";
export * from "./email-event-action";
+export * from "./login-provider";
+export * from "./features";
diff --git a/packages/common-models/src/login-provider.ts b/packages/common-models/src/login-provider.ts
new file mode 100644
index 000000000..14cd2d530
--- /dev/null
+++ b/packages/common-models/src/login-provider.ts
@@ -0,0 +1,4 @@
+import { Constants } from ".";
+
+export type LoginProvider =
+ (typeof Constants.LoginProvider)[keyof typeof Constants.LoginProvider];
diff --git a/packages/common-models/src/site-info.ts b/packages/common-models/src/site-info.ts
index bf7b149eb..e432dce20 100644
--- a/packages/common-models/src/site-info.ts
+++ b/packages/common-models/src/site-info.ts
@@ -1,3 +1,4 @@
+import { LoginProvider } from "./login-provider";
import { Media } from "./media";
import { PaymentMethod } from "./payment-method";
@@ -25,4 +26,6 @@ export default interface SiteInfo {
lemonsqueezySubscriptionMonthlyVariantId?: string;
lemonsqueezySubscriptionYearlyVariantId?: string;
lemonsqueezyWebhookSecret?: string;
+ logins?: LoginProvider[];
+ ssoTrustedDomain?: string;
}
diff --git a/packages/orm-models/src/models/domain.ts b/packages/orm-models/src/models/domain.ts
index b92d068ef..d327e2f2e 100644
--- a/packages/orm-models/src/models/domain.ts
+++ b/packages/orm-models/src/models/domain.ts
@@ -1,10 +1,15 @@
import mongoose from "mongoose";
import { SettingsSchema } from "./site-info";
-import { Domain as PublicDomain } from "@courselit/common-models";
+import {
+ Constants,
+ Features,
+ Domain as PublicDomain,
+} from "@courselit/common-models";
export interface Domain extends PublicDomain {
_id: mongoose.Types.ObjectId;
lastEditedThemeId?: string;
+ features?: Features[];
}
export const DomainSchema = new mongoose.Schema(
@@ -37,6 +42,11 @@ export const DomainSchema = new mongoose.Schema(
lastMonthlyCountUpdate: { type: Date, default: Date.now },
}),
}),
+ features: {
+ type: [String],
+ enum: Object.values(Constants.Features),
+ default: [],
+ },
},
{
timestamps: true,
diff --git a/packages/orm-models/src/models/site-info.ts b/packages/orm-models/src/models/site-info.ts
index f5d0a88f2..88218a05b 100644
--- a/packages/orm-models/src/models/site-info.ts
+++ b/packages/orm-models/src/models/site-info.ts
@@ -25,4 +25,5 @@ export const SettingsSchema = new mongoose.Schema({
lemonsqueezyOneTimeVariantId: { type: String },
lemonsqueezySubscriptionMonthlyVariantId: { type: String },
lemonsqueezySubscriptionYearlyVariantId: { type: String },
+ logins: { type: [String], enum: Object.values(Constants.LoginProvider) },
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8a772685e..77582d2f1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -188,7 +188,7 @@ importers:
version: link:../../packages/tsconfig
tsup:
specifier: ^7.2.0
- version: 7.3.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3)
+ version: 7.3.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -198,6 +198,9 @@ importers:
apps/web:
dependencies:
+ '@better-auth/sso':
+ specifier: ^1.4.6
+ version: 1.4.6(better-auth@1.4.1(next@16.0.7(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))
'@courselit/common-logic':
specifier: workspace:^
version: link:../../packages/common-logic
@@ -511,7 +514,7 @@ importers:
version: link:../tsconfig
tsup:
specifier: 6.6.0
- version: 6.6.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))(typescript@4.9.5)
+ version: 6.6.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))(typescript@4.9.5)
typescript:
specifier: ^4.9.5
version: 4.9.5
@@ -681,7 +684,7 @@ importers:
version: link:../tsconfig
tsup:
specifier: 6.6.0
- version: 6.6.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3)
+ version: 6.6.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3)
typescript:
specifier: ^5.1.6
version: 5.9.3
@@ -800,7 +803,7 @@ importers:
version: link:../tsconfig
tsup:
specifier: 6.6.0
- version: 6.6.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))(typescript@4.9.5)
+ version: 6.6.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))(typescript@4.9.5)
typescript:
specifier: ^4.9.5
version: 4.9.5
@@ -843,7 +846,7 @@ importers:
version: link:../tsconfig
tsup:
specifier: 6.6.0
- version: 6.6.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))(typescript@4.9.5)
+ version: 6.6.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))(typescript@4.9.5)
typescript:
specifier: ^4.9.5
version: 4.9.5
@@ -1187,7 +1190,7 @@ importers:
version: link:../tsconfig
tsup:
specifier: 6.6.0
- version: 6.6.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.3))(typescript@5.9.3)
+ version: 6.6.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.3))(typescript@5.9.3)
typescript:
specifier: ^5.1.6
version: 5.9.3
@@ -1337,6 +1340,10 @@ packages:
'@astrojs/webapi@1.1.1':
resolution: {integrity: sha512-yeUvP27PoiBK/WCxyQzC4HLYZo4Hg6dzRd/dTsL50WGlAQVCwWcqzVJrIZKvzNDNaW/fIXutZTmdj6nec0PIGg==}
+ '@authenio/xml-encryption@2.0.2':
+ resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
+ engines: {node: '>=12'}
+
'@aws-crypto/sha256-browser@5.2.0':
resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==}
@@ -1658,6 +1665,11 @@ packages:
kysely: ^0.28.5
nanostores: ^1.0.1
+ '@better-auth/sso@1.4.6':
+ resolution: {integrity: sha512-E6ZQLE/tunc9goQd2MEWm/W8w15i+5KmZ2yt4ngyRAQX2zHaPOOOkOMO7YAEV23f3z65hcpqinvXpkZp+Lpddg==}
+ peerDependencies:
+ better-auth: 1.4.6
+
'@better-auth/telemetry@1.4.1':
resolution: {integrity: sha512-yNeazXYvMbyuCe1AA6tYWsJEKgcS7gF9PmmACmrPVhVBe1ncDhVfWMZ++YCmA2h8hjkR9755ZyofiYRPbj+kXQ==}
peerDependencies:
@@ -5414,6 +5426,14 @@ packages:
'@vscode/l10n@0.0.18':
resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==}
+ '@xmldom/is-dom-node@1.0.1':
+ resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==}
+ engines: {node: '>= 16'}
+
+ '@xmldom/xmldom@0.8.11':
+ resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
+ engines: {node: '>=10.0.0'}
+
a11y-status@2.0.2:
resolution: {integrity: sha512-aFT18wXwGG6QHe/HsFJeQqknZ+TVi7A/3xfYMIQI5EEHIJ9ak+fa7T9uuDSpFPzNCF/4oAZyG9d/nKJMOfKgPQ==}
@@ -5593,6 +5613,9 @@ packages:
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
+ asn1@0.2.6:
+ resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
+
assert-never@1.4.0:
resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==}
@@ -7012,6 +7035,10 @@ packages:
resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==}
hasBin: true
+ fast-xml-parser@5.3.2:
+ resolution: {integrity: sha512-n8v8b6p4Z1sMgqRmqLJm3awW4NX7NkaKPfb3uJIBTSH7Pdvufi3PQ3/lJLQrvxcMYl7JI2jnDO90siPEpD8JBA==}
+ hasBin: true
+
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
@@ -8698,6 +8725,7 @@ packages:
next@16.0.7:
resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==}
engines: {node: '>=20.9.0'}
+ deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -8731,6 +8759,10 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ node-forge@1.3.3:
+ resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
+ engines: {node: '>= 6.13.0'}
+
node-gyp-build-optional-packages@5.2.2:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
@@ -8741,6 +8773,9 @@ packages:
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
+ node-rsa@1.1.1:
+ resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==}
+
nodemailer@6.10.1:
resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
engines: {node: '>=6.0.0'}
@@ -8891,6 +8926,9 @@ packages:
package-manager-detector@0.2.11:
resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -9085,6 +9123,10 @@ packages:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
preact-render-to-string@5.2.6:
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies:
@@ -9693,6 +9735,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ samlify@2.10.2:
+ resolution: {integrity: sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA==}
+
sass-formatter@0.7.9:
resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==}
@@ -10030,6 +10075,9 @@ packages:
strnum@1.1.2:
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
+ strnum@2.1.1:
+ resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==}
+
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -10559,6 +10607,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
+ uuid@8.3.2:
+ resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ hasBin: true
+
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
@@ -10803,6 +10855,13 @@ packages:
utf-8-validate:
optional: true
+ xml-crypto@6.1.2:
+ resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==}
+ engines: {node: '>=16'}
+
+ xml-escape@1.1.0:
+ resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==}
+
xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
@@ -10811,9 +10870,20 @@ packages:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
+ xml@1.0.1:
+ resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
+
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ xpath@0.0.32:
+ resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==}
+ engines: {node: '>=0.6.0'}
+
+ xpath@0.0.33:
+ resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==}
+ engines: {node: '>=0.6.0'}
+
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -11125,6 +11195,12 @@ snapshots:
global-agent: 3.0.0
node-fetch: 3.3.2
+ '@authenio/xml-encryption@2.0.2':
+ dependencies:
+ '@xmldom/xmldom': 0.8.11
+ escape-html: 1.0.3
+ xpath: 0.0.32
+
'@aws-crypto/sha256-browser@5.2.0':
dependencies:
'@aws-crypto/sha256-js': 5.2.0
@@ -11768,6 +11844,15 @@ snapshots:
nanostores: 1.1.0
zod: 4.1.12
+ '@better-auth/sso@1.4.6(better-auth@1.4.1(next@16.0.7(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))':
+ dependencies:
+ '@better-fetch/fetch': 1.1.18
+ better-auth: 1.4.1(next@16.0.7(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ fast-xml-parser: 5.3.2
+ jose: 6.1.0
+ samlify: 2.10.2
+ zod: 4.1.12
+
'@better-auth/telemetry@1.4.1(@better-auth/core@1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.1.0))':
dependencies:
'@better-auth/core': 1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.1.0)
@@ -17045,6 +17130,10 @@ snapshots:
'@vscode/l10n@0.0.18': {}
+ '@xmldom/is-dom-node@1.0.1': {}
+
+ '@xmldom/xmldom@0.8.11': {}
+
a11y-status@2.0.2:
dependencies:
'@babel/runtime': 7.27.0
@@ -17281,6 +17370,10 @@ snapshots:
asap@2.0.6: {}
+ asn1@0.2.6:
+ dependencies:
+ safer-buffer: 2.1.2
+
assert-never@1.4.0: {}
ast-types-flow@0.0.8: {}
@@ -18522,7 +18615,7 @@ snapshots:
'@next/eslint-plugin-next': 16.0.3
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7))
@@ -18553,7 +18646,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1
@@ -18568,14 +18661,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
@@ -18596,7 +18689,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -18971,6 +19064,10 @@ snapshots:
strnum: 1.1.2
optional: true
+ fast-xml-parser@5.3.2:
+ dependencies:
+ strnum: 2.1.1
+
fastq@1.19.1:
dependencies:
reusify: 1.1.0
@@ -21345,15 +21442,21 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
+ node-forge@1.3.3: {}
+
node-gyp-build-optional-packages@5.2.2:
dependencies:
- detect-libc: 2.0.4
+ detect-libc: 2.1.2
optional: true
node-int64@0.4.0: {}
node-releases@2.0.19: {}
+ node-rsa@1.1.1:
+ dependencies:
+ asn1: 0.2.6
+
nodemailer@6.10.1: {}
normalize-path@3.0.0: {}
@@ -21511,6 +21614,8 @@ snapshots:
dependencies:
quansync: 0.2.10
+ pako@1.0.11: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -21676,28 +21781,36 @@ snapshots:
postcss: 8.5.3
ts-node: 10.9.2(@types/node@20.19.0)(typescript@4.9.5)
- postcss-load-config@3.1.4(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.3)):
+ postcss-load-config@3.1.4(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
dependencies:
lilconfig: 2.1.0
yaml: 1.10.2
optionalDependencies:
postcss: 8.5.3
+ ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
+
+ postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.3)):
+ dependencies:
+ lilconfig: 2.1.0
+ yaml: 1.10.2
+ optionalDependencies:
+ postcss: 8.5.6
ts-node: 10.9.2(@types/node@20.19.0)(typescript@5.9.3)
- postcss-load-config@3.1.4(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5)):
+ postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5)):
dependencies:
lilconfig: 2.1.0
yaml: 1.10.2
optionalDependencies:
- postcss: 8.5.3
+ postcss: 8.5.6
ts-node: 10.9.2(@types/node@24.10.1)(typescript@4.9.5)
- postcss-load-config@3.1.4(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
+ postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
dependencies:
lilconfig: 2.1.0
yaml: 1.10.2
optionalDependencies:
- postcss: 8.5.3
+ postcss: 8.5.6
ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@17.0.21)(typescript@5.9.3)):
@@ -21724,6 +21837,14 @@ snapshots:
postcss: 8.5.3
ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
+ postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
+ dependencies:
+ lilconfig: 3.1.3
+ yaml: 2.7.1
+ optionalDependencies:
+ postcss: 8.5.6
+ ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
+
postcss-nested@6.2.0(postcss@8.5.3):
dependencies:
postcss: 8.5.3
@@ -21748,6 +21869,13 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+ optional: true
+
preact-render-to-string@5.2.6(preact@10.26.5):
dependencies:
preact: 10.26.5
@@ -22643,6 +22771,20 @@ snapshots:
safer-buffer@2.1.2: {}
+ samlify@2.10.2:
+ dependencies:
+ '@authenio/xml-encryption': 2.0.2
+ '@xmldom/xmldom': 0.8.11
+ camelcase: 6.3.0
+ node-forge: 1.3.3
+ node-rsa: 1.1.1
+ pako: 1.0.11
+ uuid: 8.3.2
+ xml: 1.0.1
+ xml-crypto: 6.1.2
+ xml-escape: 1.1.0
+ xpath: 0.0.32
+
sass-formatter@0.7.9:
dependencies:
suf-log: 2.5.3
@@ -23096,6 +23238,8 @@ snapshots:
strnum@1.1.2:
optional: true
+ strnum@2.1.1: {}
+
styled-jsx@5.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
@@ -23587,7 +23731,7 @@ snapshots:
- supports-color
- ts-node
- tsup@6.6.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.3))(typescript@5.9.3):
+ tsup@6.6.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3):
dependencies:
bundle-require: 4.2.1(esbuild@0.17.19)
cac: 6.7.14
@@ -23597,7 +23741,7 @@ snapshots:
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
- postcss-load-config: 3.1.4(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.3))
+ postcss-load-config: 3.1.4(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
resolve-from: 5.0.0
rollup: 3.29.5
source-map: 0.8.0-beta.0
@@ -23610,7 +23754,7 @@ snapshots:
- supports-color
- ts-node
- tsup@6.6.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))(typescript@4.9.5):
+ tsup@6.6.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.3))(typescript@5.9.3):
dependencies:
bundle-require: 4.2.1(esbuild@0.17.19)
cac: 6.7.14
@@ -23620,20 +23764,43 @@ snapshots:
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
- postcss-load-config: 3.1.4(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))
+ postcss-load-config: 3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.0)(typescript@5.9.3))
resolve-from: 5.0.0
rollup: 3.29.5
source-map: 0.8.0-beta.0
sucrase: 3.35.0
tree-kill: 1.2.2
optionalDependencies:
- postcss: 8.5.3
+ postcss: 8.5.6
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+ - ts-node
+
+ tsup@6.6.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))(typescript@4.9.5):
+ dependencies:
+ bundle-require: 4.2.1(esbuild@0.17.19)
+ cac: 6.7.14
+ chokidar: 3.6.0
+ debug: 4.4.1
+ esbuild: 0.17.19
+ execa: 5.1.1
+ globby: 11.1.0
+ joycon: 3.1.1
+ postcss-load-config: 3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@4.9.5))
+ resolve-from: 5.0.0
+ rollup: 3.29.5
+ source-map: 0.8.0-beta.0
+ sucrase: 3.35.0
+ tree-kill: 1.2.2
+ optionalDependencies:
+ postcss: 8.5.6
typescript: 4.9.5
transitivePeerDependencies:
- supports-color
- ts-node
- tsup@6.6.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3):
+ tsup@6.6.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3):
dependencies:
bundle-require: 4.2.1(esbuild@0.17.19)
cac: 6.7.14
@@ -23643,20 +23810,20 @@ snapshots:
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
- postcss-load-config: 3.1.4(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
+ postcss-load-config: 3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
resolve-from: 5.0.0
rollup: 3.29.5
source-map: 0.8.0-beta.0
sucrase: 3.35.0
tree-kill: 1.2.2
optionalDependencies:
- postcss: 8.5.3
+ postcss: 8.5.6
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- ts-node
- tsup@7.3.0(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3):
+ tsup@7.3.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3):
dependencies:
bundle-require: 4.2.1(esbuild@0.19.12)
cac: 6.7.14
@@ -23666,14 +23833,14 @@ snapshots:
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
- postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
+ postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
resolve-from: 5.0.0
rollup: 4.40.0
source-map: 0.8.0-beta.0
sucrase: 3.35.0
tree-kill: 1.2.2
optionalDependencies:
- postcss: 8.5.3
+ postcss: 8.5.6
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -23992,6 +24159,8 @@ snapshots:
uuid@11.1.0: {}
+ uuid@8.3.2: {}
+
uuid@9.0.1: {}
uvu@0.5.6:
@@ -24253,12 +24422,26 @@ snapshots:
ws@8.18.1: {}
+ xml-crypto@6.1.2:
+ dependencies:
+ '@xmldom/is-dom-node': 1.0.1
+ '@xmldom/xmldom': 0.8.11
+ xpath: 0.0.33
+
+ xml-escape@1.1.0: {}
+
xml-name-validator@4.0.0: {}
xml-name-validator@5.0.0: {}
+ xml@1.0.1: {}
+
xmlchars@2.2.0: {}
+ xpath@0.0.32: {}
+
+ xpath@0.0.33: {}
+
xtend@4.0.2: {}
y18n@5.0.8: {}