Skip to content

Commit 4bde8bd

Browse files
author
Rajat
committed
feat: implement Single Sign-On (SSO) functionality, enhance login provider management, and introduce a new features system.
1 parent ccfb0bb commit 4bde8bd

38 files changed

Lines changed: 1547 additions & 251 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- 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.
99
- 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.
1010
- Check the name field inside each package's package.json to confirm the right name—skip the top-level one.
11+
- 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`.
1112

1213
## Testing instructions
1314

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
title: Set up Single Sign On (SSO)
3+
description: Learn how to set up Single Sign On (SSO)
4+
layout: ../../../layouts/MainLayout.astro
5+
---
6+
7+
## Stuck somewhere?
8+
9+
We are always here for you. Come chat with us in our <a href="https://discord.com/invite/GR4bQsN" target="_blank">Discord</a> channel or send a tweet at <a href="https://twitter.com/courselit" target="_blank">@CourseLit</a>.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import mongoose from "mongoose";
2+
3+
mongoose.connect(process.env.DB_CONNECTION_STRING, {
4+
useNewUrlParser: true,
5+
useUnifiedTopology: true,
6+
});
7+
8+
const SettingsSchema = new mongoose.Schema({
9+
logins: { type: [String] },
10+
});
11+
12+
const DomainSchema = new mongoose.Schema({
13+
name: { type: String, required: true, unique: true },
14+
settings: SettingsSchema,
15+
});
16+
17+
const Domain = mongoose.model("Domain", DomainSchema);
18+
19+
const addEmailLoginToDomainSettings = async () => {
20+
console.log("🏁 Migrating login settings");
21+
await Domain.updateMany({}, { $set: { "settings.logins": ["email"] } });
22+
console.log("🏁 Migrated login settings");
23+
};
24+
25+
(async () => {
26+
await addEmailLoginToDomainSettings();
27+
mongoose.connection.close();
28+
})();

apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,16 @@ import { getUserProfile } from "../../helpers";
3737
import { ADMIN_PERMISSIONS } from "@ui-config/constants";
3838
import { authClient } from "@/lib/auth-client";
3939

40-
export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
40+
export default function LoginForm({
41+
redirectTo,
42+
ssoProviders,
43+
}: {
44+
redirectTo?: string;
45+
ssoProviders?: {
46+
providerId: string;
47+
domain: string;
48+
}[];
49+
}) {
4150
const { theme } = useContext(ThemeContext);
4251
const [showCode, setShowCode] = useState(false);
4352
const [email, setEmail] = useState("");
@@ -289,6 +298,26 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
289298
</div>
290299
</div>
291300
)}
301+
{ssoProviders && ssoProviders.length > 0 && (
302+
<div className="flex flex-col gap-2 mt-8">
303+
{ssoProviders.map((provider) => (
304+
<Button
305+
key={provider.providerId}
306+
variant="outline"
307+
onClick={async () => {
308+
const { error } =
309+
await authClient.signIn.sso({
310+
providerId:
311+
provider.providerId,
312+
callbackURL: "/dashboard",
313+
});
314+
}}
315+
>
316+
Login with SSO ({provider.providerId})
317+
</Button>
318+
))}
319+
</div>
320+
)}
292321
</div>
293322
</div>
294323
</div>

apps/web/app/(with-contexts)/(with-layout)/login/page.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { auth } from "@/auth";
22
import { redirect } from "next/navigation";
33
import LoginForm from "./login-form";
44
import { headers } from "next/headers";
5+
import { getAddressFromHeaders } from "@/app/actions";
6+
import { FetchBuilder } from "@courselit/utils";
57

68
export default async function LoginPage({
79
searchParams,
@@ -14,10 +16,48 @@ export default async function LoginPage({
1416
});
1517

1618
const redirectTo = (await searchParams).redirect as string | undefined;
19+
const address = await getAddressFromHeaders(headers);
1720

1821
if (session) {
1922
redirect(redirectTo || "/dashboard");
2023
}
2124

22-
return <LoginForm redirectTo={redirectTo} />;
25+
return (
26+
<LoginForm
27+
redirectTo={redirectTo}
28+
ssoProviders={await getSSOProviders(address)}
29+
/>
30+
);
2331
}
32+
33+
export const getSSOProviders = async (
34+
backend: string,
35+
): Promise<
36+
| {
37+
providerId: string;
38+
domain: string;
39+
}[]
40+
| undefined
41+
> => {
42+
const query = `
43+
query {
44+
ssoProviders: getSSOProviders {
45+
providerId
46+
domain
47+
}
48+
}
49+
`;
50+
const fetch = new FetchBuilder()
51+
.setUrl(`${backend}/api/graph`)
52+
.setPayload({ query })
53+
.setIsGraphQLEndpoint(true)
54+
.build();
55+
56+
try {
57+
const response = await fetch.exec();
58+
return response.ssoProviders;
59+
} catch (e: any) {
60+
console.log("getSSOProviders", e.message); // eslint-disable-line no-console
61+
return undefined;
62+
}
63+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { APIKEY_NEW_HEADER } from "@ui-config/strings";
2+
import type { Metadata, ResolvingMetadata } from "next";
3+
import { ReactNode } from "react";
4+
5+
export async function generateMetadata(
6+
_: any,
7+
parent: ResolvingMetadata,
8+
): Promise<Metadata> {
9+
return {
10+
title: `${APIKEY_NEW_HEADER} | ${(await parent)?.title?.absolute}`,
11+
};
12+
}
13+
14+
export default function Layout({ children }: { children: ReactNode }) {
15+
return children;
16+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use client";
2+
3+
import { useContext } from "react";
4+
import LoadingScreen from "@components/admin/loading-screen";
5+
import { checkPermission } from "@courselit/utils";
6+
import { AddressContext, ProfileContext } from "@components/contexts";
7+
import { UIConstants } from "@courselit/common-models";
8+
import DashboardContent from "@components/admin/dashboard-content";
9+
import {
10+
SITE_MISCELLANEOUS_SETTING_HEADER,
11+
SITE_SETTINGS_PAGE_HEADING,
12+
SSO_PROVIDER_NEW_HEADER,
13+
} from "@ui-config/strings";
14+
import dynamic from "next/dynamic";
15+
const { permissions } = UIConstants;
16+
17+
const SSOProviderNew = dynamic(
18+
() => import("@/components/admin/settings/sso/new"),
19+
);
20+
21+
const breadcrumbs = [
22+
{
23+
label: SITE_SETTINGS_PAGE_HEADING,
24+
href: `/dashboard/settings?tab=${SITE_MISCELLANEOUS_SETTING_HEADER}`,
25+
},
26+
{ label: SSO_PROVIDER_NEW_HEADER, href: "#" },
27+
];
28+
29+
export default function Page() {
30+
const address = useContext(AddressContext);
31+
const { profile } = useContext(ProfileContext);
32+
33+
if (
34+
!profile ||
35+
!checkPermission(profile.permissions!, [permissions.manageSettings])
36+
) {
37+
return <LoadingScreen />;
38+
}
39+
40+
return (
41+
<DashboardContent breadcrumbs={breadcrumbs}>
42+
<SSOProviderNew address={address} />
43+
</DashboardContent>
44+
);
45+
}

apps/web/app/(with-contexts)/layout-with-context.tsx

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import {
88
useCallback,
99
startTransition,
1010
} from "react";
11-
import { SiteInfo, ServerConfig } from "@courselit/common-models";
11+
import { SiteInfo, ServerConfig, Features } from "@courselit/common-models";
1212
import {
1313
AddressContext,
1414
ProfileContext,
1515
SiteInfoContext,
1616
ServerConfigContext,
1717
ThemeContext,
18+
FeaturesContext,
1819
} from "@components/contexts";
1920
import { Toaster, useToast } from "@courselit/components-library";
2021
import { TOAST_TITLE_ERROR } from "@ui-config/strings";
@@ -33,13 +34,15 @@ function LayoutContent({
3334
theme: initialTheme,
3435
config,
3536
session,
37+
features,
3638
}: {
3739
address: string;
3840
children: ReactNode;
3941
siteinfo: SiteInfo;
4042
theme: Theme;
4143
config: ServerConfig;
4244
session: BetterAuthSession;
45+
features: Features[];
4346
}) {
4447
const [profile, setProfile] = useState(defaultState.profile);
4548
const [theme, setTheme] = useState(initialTheme);
@@ -77,25 +80,29 @@ function LayoutContent({
7780
frontend: address,
7881
}}
7982
>
80-
<SiteInfoContext.Provider value={siteinfo}>
81-
<ThemeContext.Provider value={{ theme, setTheme }}>
82-
<ServerConfigContext.Provider value={config}>
83-
<NextThemesProvider
84-
attribute="class"
85-
defaultTheme="system"
86-
enableSystem
87-
disableTransitionOnChange
88-
>
89-
<ProfileContext.Provider
90-
value={{ profile, setProfile }}
83+
<FeaturesContext.Provider value={features}>
84+
<SiteInfoContext.Provider value={siteinfo}>
85+
<ThemeContext.Provider value={{ theme, setTheme }}>
86+
<ServerConfigContext.Provider value={config}>
87+
<NextThemesProvider
88+
attribute="class"
89+
defaultTheme="system"
90+
enableSystem
91+
disableTransitionOnChange
9192
>
92-
<Suspense fallback={null}>{children}</Suspense>
93-
</ProfileContext.Provider>
94-
</NextThemesProvider>
95-
</ServerConfigContext.Provider>
96-
</ThemeContext.Provider>
97-
</SiteInfoContext.Provider>
98-
<Toaster />
93+
<ProfileContext.Provider
94+
value={{ profile, setProfile }}
95+
>
96+
<Suspense fallback={null}>
97+
{children}
98+
</Suspense>
99+
</ProfileContext.Provider>
100+
</NextThemesProvider>
101+
</ServerConfigContext.Provider>
102+
</ThemeContext.Provider>
103+
</SiteInfoContext.Provider>
104+
<Toaster />
105+
</FeaturesContext.Provider>
99106
</AddressContext.Provider>
100107
);
101108
}
@@ -107,6 +114,7 @@ export default function Layout(props: {
107114
theme: Theme;
108115
config: ServerConfig;
109116
session: BetterAuthSession;
117+
features: Features[];
110118
}) {
111119
return (
112120
<Suspense fallback={null}>

apps/web/app/(with-contexts)/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default async function Layout({
3232
theme={siteSetup?.theme || defaultState.theme}
3333
config={config}
3434
session={session}
35+
features={siteSetup?.features || defaultState.features}
3536
>
3637
{children}
3738
</LayoutWithContext>

apps/web/app/verify-domain/route.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { headers } from "next/headers";
77
import connectToDatabase from "../../services/db";
88
import { warn } from "@/services/logger";
99
import SubscriberModel, { Subscriber } from "@models/Subscriber";
10+
import { Constants } from "@courselit/common-models";
1011

1112
const { domainNameForSingleTenancy, schoolNameForSingleTenancy } = constants;
1213

@@ -107,8 +108,6 @@ export async function GET(req: Request) {
107108

108109
const currentDate = new Date();
109110
const dateAfter24Hours = new Date(currentDate.getTime() + 86400000);
110-
// domain.checkSubscriptionStatusAfter = dateAfter24Hours;
111-
// await (domain as any).save({ timestamps: true });
112111
await DomainModel.findOneAndUpdate(
113112
{ _id: domain!._id },
114113
{ $set: { checkSubscriptionStatusAfter: dateAfter24Hours } },
@@ -145,7 +144,13 @@ export async function GET(req: Request) {
145144
},
146145
settings: {
147146
title: schoolNameForSingleTenancy,
147+
logins: [Constants.LoginProvider.EMAIL],
148148
},
149+
features: [
150+
Constants.Features.SSO,
151+
Constants.Features.API,
152+
Constants.Features.LOG,
153+
],
149154
},
150155
{
151156
upsert: true,

0 commit comments

Comments
 (0)