diff --git a/apps/web/app/(with-contexts)/(with-layout)/blog/content-card.tsx b/apps/web/app/(with-contexts)/(with-layout)/blog/content-card.tsx index 41431718f..fd6fd0d4c 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/blog/content-card.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/blog/content-card.tsx @@ -11,6 +11,7 @@ import { Subheader1, } from "@courselit/page-primitives"; import Link from "next/link"; +import { UNNAMED_USER } from "@ui-config/strings"; export function BlogContentCard({ product }: { product: Course }) { const { theme: uiTheme } = useContext(ThemeContext); @@ -50,7 +51,10 @@ export function BlogContentCard({ product }: { product: Course }) { height="h-8" /> - {truncate(product.user?.name || "Unnamed", 20)} + {truncate( + product.user?.name || UNNAMED_USER, + 20, + )} diff --git a/apps/web/app/(with-contexts)/(with-layout)/first-run-popup.tsx b/apps/web/app/(with-contexts)/(with-layout)/first-run-popup.tsx new file mode 100644 index 000000000..01ecaa63d --- /dev/null +++ b/apps/web/app/(with-contexts)/(with-layout)/first-run-popup.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@components/ui/alert-dialog"; +import { AlertDialogCancel } from "@radix-ui/react-alert-dialog"; +import Link from "next/link"; +import { useState } from "react"; + +export default function FirstRunPopup() { + const [open, setOpen] = useState(true); + + return ( + + + + + Welcome to your new school! 🎉 + + + You are almost ready to monetize your knowledge. + + + + setOpen(false)}> + I'll do it on my own + + + Continue setup + + + + + ); +} 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 fad10c976..dd73adb41 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 @@ -1,6 +1,10 @@ "use client"; -import { ServerConfigContext, ThemeContext } from "@components/contexts"; +import { + AddressContext, + ServerConfigContext, + ThemeContext, +} from "@components/contexts"; import { Button, Caption, @@ -13,12 +17,7 @@ import { import { useContext, useState } from "react"; import { FormEvent } from "react"; import { signIn } from "next-auth/react"; -import { - Form, - // FormField, - // FormSubmit, - useToast, -} from "@courselit/components-library"; +import { Form, useToast } from "@courselit/components-library"; import { BTN_LOGIN, BTN_LOGIN_GET_CODE, @@ -34,6 +33,10 @@ import Link from "next/link"; 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 { getUserProfile } from "../../helpers"; +import { ADMIN_PERMISSIONS } from "@ui-config/constants"; export default function LoginForm({ redirectTo }: { redirectTo?: string }) { const { theme } = useContext(ThemeContext); @@ -45,6 +48,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { const { toast } = useToast(); const serverConfig = useContext(ServerConfigContext); const { executeRecaptcha } = useRecaptcha(); + const address = useContext(AddressContext); const requestCode = async function (e: FormEvent) { e.preventDefault(); @@ -152,13 +156,28 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { if (response?.error) { setError(`Can't sign you in at this time`); } else { - window.location.href = redirectTo || "/dashboard/my-content"; + window.location.href = + redirectTo || + getRedirectURLBasedOnProfile( + await getUserProfile(address.backend), + ); } } finally { setLoading(false); } }; + const getRedirectURLBasedOnProfile = (profile: Profile) => { + if ( + profile?.userId && + checkPermission(profile.permissions!, ADMIN_PERMISSIONS) + ) { + return "/dashboard/overview"; + } else { + return "/dashboard/my-content"; + } + }; + return (
@@ -251,10 +270,6 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { theme={theme.theme} />
- {/* */} + + + + ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/layout-with-sidebar.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/layout-with-sidebar.tsx index fb6695dc0..75678409c 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/layout-with-sidebar.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/layout-with-sidebar.tsx @@ -6,7 +6,11 @@ import { ThemeContext } from "@components/contexts"; import { themes } from "@courselit/page-primitives"; import { Theme } from "@courselit/page-models"; -export default function Layout({ children }: { children: React.ReactNode }) { +export default function LayoutWithSidebar({ + children, +}: { + children: React.ReactNode; +}) { const classicTheme = themes.find((theme) => theme.id === "classic"); const theme: Theme = { id: "classic", diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx index 2fcccde76..9822704e0 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/mail-hub.tsx @@ -2,7 +2,6 @@ import Mails from "@components/admin/mails"; import { AddressContext, ProfileContext } from "@components/contexts"; -import { checkPermission } from "@courselit/utils"; import { useSearchParams } from "next/navigation"; import { useContext } from "react"; import { UIConstants } from "@courselit/common-models"; @@ -20,12 +19,15 @@ export default function MailHub() { const breadcrumbs = [{ label: tab, href: "#" }]; - if (!checkPermission(profile?.permissions!, [permissions.manageSite])) { + if (!profile) { return ; } return ( - + ); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/overview/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/overview/page.tsx index 6d0df28da..ceb9a4c9b 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/overview/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/overview/page.tsx @@ -1,27 +1,17 @@ "use client"; import { useContext, useState } from "react"; -import { - AddressContext, - ProfileContext, - SiteInfoContext, -} from "@components/contexts"; -import { UIConstants, Constants } from "@courselit/common-models"; -import { checkPermission } from "@courselit/utils"; +import { ProfileContext } from "@components/contexts"; +import { Constants } from "@courselit/common-models"; import { DASHBOARD_PAGE_HEADER, OVERVIEW_HEADER, UNNAMED_USER, } from "@ui-config/strings"; -import { TIME_RANGES } from "@ui-config/constants"; +import { ADMIN_PERMISSIONS, TIME_RANGES } from "@ui-config/constants"; import { useActivities } from "@/hooks/use-activities"; import dynamic from "next/dynamic"; import DashboardContent from "@components/admin/dashboard-content"; -const Todo = dynamic(() => - import("@components/admin/dashboard/to-do").then((mod) => ({ - default: mod.default, - })), -); const LoadingScreen = dynamic(() => import("@components/admin/loading-screen")); const MetricCard = dynamic(() => import("../product/[id]/metric-card")); const SalesCard = dynamic(() => import("./sales-card")); @@ -67,8 +57,6 @@ const Mail = dynamic(() => const breadcrumbs = [{ label: OVERVIEW_HEADER, href: "#" }]; export default function Page() { - const siteInfo = useContext(SiteInfoContext); - const address = useContext(AddressContext); const { profile } = useContext(ProfileContext); const [timeRange, setTimeRange] = useState("7d"); const { data: salesData, loading: salesLoading } = useActivities( @@ -78,21 +66,15 @@ export default function Page() { true, ); - if ( - !checkPermission(profile.permissions!, [ - UIConstants.permissions.manageAnyCourse, - UIConstants.permissions.manageCourse, - UIConstants.permissions.manageMedia, - UIConstants.permissions.manageSettings, - UIConstants.permissions.manageSite, - UIConstants.permissions.manageUsers, - ]) - ) { + if (!profile || !profile.userId) { return ; } return ( - +

{DASHBOARD_PAGE_HEADER},{" "} @@ -116,25 +98,6 @@ export default function Page() {

- {/*

- {DASHBOARD_PAGE_HEADER}, {profile.name ? profile.name.split(" ")[0] : ""} -

- */} -
- {/*
- - - - -
*/} ); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/pages/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/pages/page.tsx index 4685b0d20..5ab09fe3a 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/pages/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/pages/page.tsx @@ -4,7 +4,6 @@ import DashboardContent from "@components/admin/dashboard-content"; import LoadingScreen from "@components/admin/loading-screen"; import { ProfileContext } from "@components/contexts"; import { UIConstants } from "@courselit/common-models"; -import { checkPermission } from "@courselit/utils"; import { MANAGE_PAGES_PAGE_HEADING } from "@ui-config/strings"; import dynamic from "next/dynamic"; import { useContext } from "react"; @@ -17,15 +16,15 @@ const breadcrumbs = [{ label: MANAGE_PAGES_PAGE_HEADING, href: "#" }]; export default function Page() { const { profile } = useContext(ProfileContext); - if ( - !profile || - !checkPermission(profile.permissions!, [permissions.manageSite]) - ) { + if (!profile) { return ; } return ( - + ); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/page.tsx index ad9772c38..1afc38e1e 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/settings/page.tsx @@ -5,7 +5,6 @@ import LoadingScreen from "@components/admin/loading-screen"; import Settings from "@components/admin/settings"; import { ProfileContext, SiteInfoContext } from "@components/contexts"; import { Profile, UIConstants } from "@courselit/common-models"; -import { checkPermission } from "@courselit/utils"; import { SITE_SETTINGS_PAGE_HEADING } from "@ui-config/strings"; import { useSearchParams } from "next/navigation"; import { useContext } from "react"; @@ -20,15 +19,15 @@ export default function Page() { const tab = searchParams?.get("tab") || "Branding"; - if ( - !profile || - !checkPermission(profile.permissions!, [permissions.manageSettings]) - ) { + if (!profile) { return ; } return ( - + +

{HEADER_HELP}

diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/users-hub.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/users-hub.tsx index d44ecebf0..18d387671 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/users-hub.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/users/users-hub.tsx @@ -29,7 +29,7 @@ import { useToast, Skeleton, } from "@courselit/components-library"; -import { checkPermission, FetchBuilder } from "@courselit/utils"; +import { FetchBuilder } from "@courselit/utils"; import { TOAST_TITLE_ERROR, USER_TABLE_HEADER_COMMUNITIES, @@ -141,12 +141,15 @@ export default function UsersHub() { setPage(1); }, []); - if (!checkPermission(profile.permissions!, [permissions.manageUsers])) { + if (!profile) { return ; } return ( - +

{USERS_MANAGER_PAGE_HEADING} diff --git a/apps/web/app/(with-contexts)/dashboard/layout.tsx b/apps/web/app/(with-contexts)/dashboard/layout.tsx deleted file mode 100644 index 39439e010..000000000 --- a/apps/web/app/(with-contexts)/dashboard/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; -import { auth } from "@/auth"; -import { redirect } from "next/navigation"; - -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const session = await auth(); - if (!session) { - redirect("/login?redirect=/dashboard"); - } - - return children; -} diff --git a/apps/web/app/(with-contexts)/dashboard/page.tsx b/apps/web/app/(with-contexts)/dashboard/page.tsx new file mode 100644 index 000000000..92d70d583 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/page.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation"; +import { getProfile } from "../action"; +import { Profile } from "@courselit/common-models"; +import { checkPermission } from "@courselit/utils"; +import { ADMIN_PERMISSIONS } from "@ui-config/constants"; + +export default async function Page() { + const profile = (await getProfile()) as Profile; + if (checkPermission(profile.permissions, ADMIN_PERMISSIONS)) { + redirect("/dashboard/overview"); + } else { + redirect("/dashboard/my-content"); + } + + return null; +} diff --git a/apps/web/app/(with-contexts)/layout-with-context.tsx b/apps/web/app/(with-contexts)/layout-with-context.tsx index 9feeb8a07..7233ef713 100644 --- a/apps/web/app/(with-contexts)/layout-with-context.tsx +++ b/apps/web/app/(with-contexts)/layout-with-context.tsx @@ -94,7 +94,6 @@ export default function Layout(props: { theme: Theme; config: ServerConfig; session: Session | null; - // profile: Partial | null; }) { return ( @@ -102,7 +101,3 @@ export default function Layout(props: { ); } - -// function formatHSL(hsl: HSL): string { -// return `${hsl[0]} ${hsl[1]}% ${hsl[2]}%`; -// } diff --git a/apps/web/app/actions.ts b/apps/web/app/actions.ts index 412b8ac05..6f89f4c0e 100644 --- a/apps/web/app/actions.ts +++ b/apps/web/app/actions.ts @@ -1,13 +1,13 @@ import { headers as headersType } from "next/headers"; -export const getBackendAddress = ( +export async function getBackendAddress( headers: Headers, -): `${string}://${string}` => { +): Promise<`${string}://${string}`> { return `${headers.get("x-forwarded-proto")}://${headers.get("host")}`; -}; +} export async function getAddressFromHeaders(headers: typeof headersType) { const headersList = await headers(); - const address = getBackendAddress(headersList); + const address = await getBackendAddress(headersList); return address; } diff --git a/apps/web/app/api/auth/code/generate/route.ts b/apps/web/app/api/auth/code/generate/route.ts index 1c1443bcc..738565346 100644 --- a/apps/web/app/api/auth/code/generate/route.ts +++ b/apps/web/app/api/auth/code/generate/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; import { responses } from "@/config/strings"; -import { generateUniquePasscode, hashCode } from "@/ui-lib/utils"; +import { generateUniquePasscode, hashCode } from "@/lib/utils"; import VerificationToken from "@/models/VerificationToken"; import pug from "pug"; import MagicCodeEmailTemplate from "@/templates/magic-code-email"; diff --git a/apps/web/app/verify-domain/route.ts b/apps/web/app/verify-domain/route.ts index 816fb7e3b..1f170f778 100644 --- a/apps/web/app/verify-domain/route.ts +++ b/apps/web/app/verify-domain/route.ts @@ -1,11 +1,12 @@ import DomainModel, { Domain } from "../../models/Domain"; import { responses } from "../../config/strings"; -import constants from "../../config/constants"; +import constants from "@/config/constants"; import { isDateInFuture } from "../../lib/utils"; import { createUser } from "../../graphql/users/logic"; import { headers } from "next/headers"; import connectToDatabase from "../../services/db"; import { warn } from "@/services/logger"; +import SubscriberModel, { Subscriber } from "@models/Subscriber"; const { domainNameForSingleTenancy, schoolNameForSingleTenancy } = constants; @@ -41,7 +42,7 @@ export async function GET(req: Request) { await connectToDatabase(); - if (process.env.MULTITENANT === "true") { + if (constants.multitenant) { const host = headerList.get("host"); if (!host) { @@ -160,6 +161,9 @@ export async function GET(req: Request) { domain: domain!, email: domain!.email, superAdmin: true, + name: constants.multitenant + ? await getSubscriberName(domain!.email) + : "", }); await DomainModel.findOneAndUpdate( { _id: domain!._id }, @@ -181,3 +185,12 @@ export async function GET(req: Request) { logo: domain!.settings?.logo?.file, }); } + +async function getSubscriberName(email: string): Promise { + const subscriber = (await SubscriberModel.findOne( + { email }, + { name: 1, _id: 0 }, + ).lean()) as unknown as Subscriber; + + return subscriber ? subscriber.name : ""; +} diff --git a/apps/web/auth.ts b/apps/web/auth.ts index 250bd732e..fb8ce5ceb 100644 --- a/apps/web/auth.ts +++ b/apps/web/auth.ts @@ -1,13 +1,15 @@ -import NextAuth from "next-auth"; +import NextAuth, { Session } from "next-auth"; import { z } from "zod"; import { authConfig } from "./auth.config"; import CredentialsProvider from "next-auth/providers/credentials"; import VerificationToken from "@models/VerificationToken"; -import User from "@models/User"; +import UserModel from "@models/User"; import { createUser } from "./graphql/users/logic"; -import { hashCode } from "@ui-lib/utils"; +import { hashCode } from "@/lib/utils"; import DomainModel, { Domain } from "@models/Domain"; import { error } from "./services/logger"; +import { User } from "next-auth"; +import { User as AppUser } from "@courselit/common-models"; export const { auth, signIn, signOut, handlers } = NextAuth({ ...authConfig, @@ -49,7 +51,7 @@ export const { auth, signIn, signOut, handlers } = NextAuth({ return null; } - let user = await User.findOne({ + let user = await UserModel.findOne({ domain: domain._id, email: sanitizedEmail, }); @@ -66,12 +68,28 @@ export const { auth, signIn, signOut, handlers } = NextAuth({ if (!user.active) { return null; } - return { - id: user.userId, - email: sanitizedEmail, - name: user.name, - }; + return user; }, }), ], + callbacks: { + jwt({ token, user }: { token: any; user?: User }) { + if (user) { + token.userId = (user as unknown as AppUser).userId; + token.domain = (user as any).domain.toString(); + } + return token; + }, + session({ session, token }: { session: Session; token: any }) { + if (session.user && token.userId) { + if (token.userId) { + (session.user as any).userId = token.userId; + } + if (token.domain) { + (session.user as any).domain = token.domain; + } + } + return session; + }, + }, }); diff --git a/apps/web/components/admin/dashboard-content.tsx b/apps/web/components/admin/dashboard-content.tsx index 96a7101d3..18b4c76cc 100644 --- a/apps/web/components/admin/dashboard-content.tsx +++ b/apps/web/components/admin/dashboard-content.tsx @@ -32,7 +32,7 @@ export default function DashboardContent({ }) { const { profile } = useContext(ProfileContext); - if (!profile.userId) { + if (!profile || !profile.userId) { return ; } diff --git a/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx index 04b5f80ec..bd2f832d1 100644 --- a/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx +++ b/apps/web/components/admin/dashboard-skeleton/app-sidebar.tsx @@ -32,6 +32,7 @@ import { ProfileContext, SiteInfoContext } from "@components/contexts"; import { checkPermission } from "@courselit/utils"; import { Profile, UIConstants } from "@courselit/common-models"; import { + GET_SET_UP, MY_CONTENT_HEADER, SIDEBAR_MENU_BLOGS, SIDEBAR_MENU_MAILS, @@ -41,7 +42,11 @@ import { } from "@ui-config/strings"; import { NavSecondary } from "./nav-secondary"; import { usePathname, useSearchParams } from "next/navigation"; -import { ComponentProps, useContext } from "react"; +import { ComponentProps, useContext, useEffect, useState } from "react"; +import { CircularProgress } from "@components/circular-progress"; +import { hasPermissionToAccessSetupChecklist } from "@/lib/utils"; +import { ADMIN_PERMISSIONS } from "@ui-config/constants"; +import { getSetupChecklist } from "@/app/(with-contexts)/dashboard/(sidebar)/action"; const { permissions } = UIConstants; export function AppSidebar({ ...props }: ComponentProps) { @@ -50,9 +55,36 @@ export function AppSidebar({ ...props }: ComponentProps) { const path = usePathname(); const searchParams = useSearchParams(); const tab = searchParams?.get("tab"); + const [checklist, setChecklist] = useState([]); + const [totalChecklistItems, setTotalChecklistItems] = useState(0); + + useEffect(() => { + const loadChecklist = async () => { + try { + const setupChecklist = await getSetupChecklist(); + if (!setupChecklist) { + return; + } + setChecklist(setupChecklist.checklist); + setTotalChecklistItems(setupChecklist.total); + } catch (error) {} + }; + + if ( + profile && + profile.userId && + hasPermissionToAccessSetupChecklist(profile.permissions!) + ) { + loadChecklist(); + } + }, [profile]); + + if (!profile) { + return null; + } const { navMainItems, navProjectItems, navSecondaryItems } = - getSidebarItems(profile, path, tab); + getSidebarItems({ profile, path, tab, checklist, totalChecklistItems }); return ( @@ -70,9 +102,6 @@ export function AppSidebar({ ...props }: ComponentProps) { alt="logo" />

- {/*
- -
*/}
{siteInfo.title} @@ -96,7 +125,19 @@ export function AppSidebar({ ...props }: ComponentProps) { ); } -function getSidebarItems(profile: Partial, path, tab) { +function getSidebarItems({ + profile, + checklist = [], + totalChecklistItems = 0, + path, + tab, +}: { + profile: Partial; + checklist: string[]; + totalChecklistItems: number; + path?: string | null; + tab?: string | null; +}) { const navMainItems: any[] = []; if ( @@ -110,7 +151,6 @@ function getSidebarItems(profile: Partial, path, tab) { url: "/dashboard/overview", icon: Target, isActive: path === "/dashboard/overview", - // items: [], }); navMainItems.push({ title: "Products", @@ -118,7 +158,7 @@ function getSidebarItems(profile: Partial, path, tab) { icon: Box, isActive: path === "/dashboard/products" || - path.startsWith("/dashboard/product"), + path?.startsWith("/dashboard/product"), items: [], }); } @@ -139,7 +179,7 @@ function getSidebarItems(profile: Partial, path, tab) { icon: Text, isActive: path === "/dashboard/blogs" || - path.startsWith("/dashboard/blog"), + path?.startsWith("/dashboard/blog"), items: [], }); } @@ -150,7 +190,7 @@ function getSidebarItems(profile: Partial, path, tab) { icon: Globe, isActive: path === "/dashboard/pages" || - path.startsWith("/dashboard/page"), + path?.startsWith("/dashboard/page"), items: [], }); } @@ -179,8 +219,8 @@ function getSidebarItems(profile: Partial, path, tab) { url: "#", icon: Mail, isActive: - path.startsWith("/dashboard/mails") || - path.startsWith("/dashboard/mail"), + path?.startsWith("/dashboard/mails") || + path?.startsWith("/dashboard/mail"), items: [ { title: "Broadcasts", @@ -242,14 +282,36 @@ function getSidebarItems(profile: Partial, path, tab) { }); } - const navSecondaryItems = [ - { + const navSecondaryItems: any[] = []; + if ( + profile && + profile.permissions && + checkPermission(profile.permissions, ADMIN_PERMISSIONS) + ) { + if (totalChecklistItems && checklist.length) { + navSecondaryItems.push({ + title: GET_SET_UP, + url: "/dashboard/get-set-up", + icon: ( + + ), + isActive: path === "/dashboard/get-set-up", + }); + } + navSecondaryItems.push({ title: "Support", url: "/dashboard/support", - icon: LifeBuoy, + icon: , isActive: path === "/dashboard/support", - }, - ]; + }); + } const navProjectItems = [ { name: MY_CONTENT_HEADER, diff --git a/apps/web/components/admin/dashboard-skeleton/nav-secondary.tsx b/apps/web/components/admin/dashboard-skeleton/nav-secondary.tsx index 4b446d1f1..29d857aad 100644 --- a/apps/web/components/admin/dashboard-skeleton/nav-secondary.tsx +++ b/apps/web/components/admin/dashboard-skeleton/nav-secondary.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { type LucideIcon } from "lucide-react"; import { SidebarGroup, @@ -17,7 +16,7 @@ export function NavSecondary({ items: { title: string; url: string; - icon: LucideIcon; + icon: any; isActive?: boolean; }[]; } & React.ComponentPropsWithoutRef) { @@ -34,7 +33,7 @@ export function NavSecondary({ tooltip={item.title} > - + {item.icon} {item.title} diff --git a/apps/web/components/admin/dashboard/to-do.tsx b/apps/web/components/admin/dashboard/to-do.tsx deleted file mode 100644 index b27f7671e..000000000 --- a/apps/web/components/admin/dashboard/to-do.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { SiteInfoContext } from "@components/contexts"; -import { Link } from "@courselit/components-library"; -import { - SITE_SETTINGS_SECTION_GENERAL, - SITE_SETTINGS_SECTION_PAYMENT, -} from "@ui-config/strings"; -import { useContext } from "react"; - -export default function Todo() { - const siteinfo = useContext(SiteInfoContext); - - return ( -
- {(!siteinfo.title || (siteinfo.logo && !siteinfo.logo.file)) && ( -
-

- Basic details missing 💁‍♀️ -

-

- Give your school a proper name, description and a logo. -

-
- - - Update now - - -
-
- )} - {(!siteinfo.currencyISOCode || !siteinfo.paymentMethod) && ( -
-

Start earning 💸

-

- Update your payment details to sell paid products. -

-
- - - Update now - - -
-
- )} -
- ); -} diff --git a/apps/web/components/admin/settings/index.tsx b/apps/web/components/admin/settings/index.tsx index 04c839535..cd9eaf6f3 100644 --- a/apps/web/components/admin/settings/index.tsx +++ b/apps/web/components/admin/settings/index.tsx @@ -81,7 +81,6 @@ import { CardTitle, } from "@components/ui/card"; import { Copy, Info } from "lucide-react"; -import { Label } from "@components/ui/label"; import { Input } from "@components/ui/input"; import Resources from "@components/resources"; import { AddressContext } from "@components/contexts"; @@ -1068,7 +1067,6 @@ const Settings = (props: SettingsProps) => {
-
{
-
- -
- - -
-
{ + value: number; + className?: string; + /** + * Matches lucide/shadcn pattern: viewBox stays 0 0 24 24 but rendered size can be + * controlled via `className` (e.g. "h-6 w-6") or overridden via this prop. + */ + size?: number; + strokeWidth?: number; +} + +// https://github.com/shadcn-ui/ui/issues/697 +// https://github.com/shadcn-ui/ui/issues/697#issuecomment-2621653578 CircularProgress + +function clamp(input: number, a: number, b: number): number { + return Math.max(Math.min(input, Math.max(a, b)), Math.min(a, b)); +} + +// fix to percentage values +const total = 100; + +/** + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/progress + * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/progressbar_role + */ +export const CircularProgress = ({ + value, + className, + size = 24, + strokeWidth = 2, + ...restSvgProps +}: ProgressCircleProps): any => { + const normalizedValue = clamp(value, 0, total); + + // geometry is based on the viewBox size (default 24) to match lucide icons. + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const progress = (normalizedValue / total) * circumference; + const halfSize = size / 2; + + const commonParams = { + cx: halfSize, + cy: halfSize, + r: radius, + fill: "none", + strokeWidth, + }; + + return ( + // biome-ignore lint/a11y/useFocusableInteractive: false positive (progress + progressbar are not focusable interactives) + // biome-ignore lint/nursery/useAriaPropsSupportedByRole: biome rule at odds with mdn docs (presumed nursary bug with rule) + + + + + ); +}; diff --git a/apps/web/config/constants.ts b/apps/web/config/constants.ts index 9a843ff06..47c5e9f63 100644 --- a/apps/web/config/constants.ts +++ b/apps/web/config/constants.ts @@ -5,6 +5,7 @@ import { UIConstants } from "@courselit/common-models"; const { permissions } = UIConstants; export default { + multitenant: process.env.MULTITENANT === "true", domainNameForSingleTenancy: "main", schoolNameForSingleTenancy: "My school", dbConnectionString: diff --git a/apps/web/graphql/courses/helpers.ts b/apps/web/graphql/courses/helpers.ts index 107f79a0d..e5be7eae6 100644 --- a/apps/web/graphql/courses/helpers.ts +++ b/apps/web/graphql/courses/helpers.ts @@ -1,27 +1,13 @@ -import { getPaymentMethod } from "../../payments"; import { internal, responses } from "../../config/strings"; import GQLContext from "../../models/GQLContext"; -import CourseModel, { InternalCourse } from "../../models/Course"; +import CourseModel from "../../models/Course"; import constants from "../../config/constants"; -import { Progress } from "../../models/Progress"; -import { User } from "../../models/User"; import Page from "../../models/Page"; import slugify from "slugify"; import { addGroup } from "./logic"; -import { Constants, Course } from "@courselit/common-models"; +import { Constants, Course, Progress, User } from "@courselit/common-models"; import { getPlans } from "../paymentplans/logic"; - -const validatePaymentMethod = async (domain: string) => { - try { - await getPaymentMethod(domain); - } catch (err: any) { - if (err.message === responses.update_payment_method) { - throw err; - } else { - throw new Error(responses.internal_error); - } - } -}; +import { InternalCourse } from "@courselit/common-logic"; export const validateCourse = async ( courseData: InternalCourse, diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index c731d1f26..39bb1a1db 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -162,7 +162,12 @@ export const createCourse = async ( ctx: GQLContext, ) => { checkIfAuthenticated(ctx); - if (!checkPermission(ctx.user.permissions, [permissions.manageCourse])) { + if ( + !checkPermission(ctx.user.permissions, [ + permissions.manageAnyCourse, + permissions.manageCourse, + ]) + ) { throw new Error(responses.action_not_allowed); } diff --git a/apps/web/graphql/paymentplans/logic.ts b/apps/web/graphql/paymentplans/logic.ts index 9bd6c05a3..6a333f2cb 100644 --- a/apps/web/graphql/paymentplans/logic.ts +++ b/apps/web/graphql/paymentplans/logic.ts @@ -52,7 +52,10 @@ function checkEntityManagementPermission( ) { if (entityType === membershipEntityType.COURSE) { if ( - !checkPermission(ctx.user.permissions, [permissions.manageCourse]) + !checkPermission(ctx.user.permissions, [ + permissions.manageAnyCourse, + permissions.manageCourse, + ]) ) { throw new Error(responses.action_not_allowed); } diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index 7142d7a2d..f488bbcb3 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -1,3 +1,6 @@ +import { UIConstants } from "@courselit/common-models"; +import { createHash, randomInt } from "crypto"; + export const capitalize = (s: string) => { if (typeof s !== "string") return ""; return s.charAt(0).toUpperCase() + s.slice(1); @@ -43,3 +46,30 @@ export const generateEmailFrom = ({ }) => { return `${name} <${email}>`; }; + +export const hasPermissionToAccessSetupChecklist = ( + userPermissions: string[], +) => { + const { permissions } = UIConstants; + const REQUIRED_PERMISSIONS_FOR_SETUP_CHECKLIST = [ + permissions.manageAnyCourse, + permissions.manageSettings, + permissions.manageSite, + permissions.publishCourse, + ] as const; + + return REQUIRED_PERMISSIONS_FOR_SETUP_CHECKLIST.every((perm) => + userPermissions.includes(perm), + ); +}; + +export function generateUniquePasscode() { + return randomInt(100000, 999999); +} + +// Inspired from: https://github.com/nextauthjs/next-auth/blob/c4ad77b86762b7fd2e6362d8bf26c5953846774a/packages/next-auth/src/core/lib/utils.ts#L16 +export function hashCode(code: number) { + return createHash("sha256") + .update(`${code}${process.env.AUTH_SECRET}`) + .digest("hex"); +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 1ed6a18d9..89f20c4cf 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -7,7 +7,7 @@ const { auth } = NextAuth(authConfig); export default auth(async (request: NextRequest) => { const requestHeaders = request.headers; - const backend = getBackendAddress(requestHeaders); + const backend = await getBackendAddress(requestHeaders); if (request.nextUrl.pathname === "/healthy") { return Response.json({ success: true }); @@ -52,6 +52,20 @@ export default auth(async (request: NextRequest) => { } } + if (request.nextUrl.pathname.startsWith("/dashboard")) { + const session = await auth(); + if (!session) { + return NextResponse.redirect( + new URL( + `/login?redirect=${encodeURIComponent( + request.nextUrl.pathname, + )}`, + request.url, + ), + ); + } + } + return NextResponse.next({ request: { headers: requestHeaders, @@ -66,6 +80,12 @@ export default auth(async (request: NextRequest) => { }); export const config = { - matcher: ["/", "/favicon.ico", "/api/:path*", "/healthy"], + matcher: [ + "/", + "/favicon.ico", + "/api/:path*", + "/healthy", + "/dashboard/:path*", + ], unstable_allowDynamic: ["/node_modules/next-auth/**"], }; diff --git a/apps/web/models/Subscriber.ts b/apps/web/models/Subscriber.ts new file mode 100644 index 000000000..2cb11a11b --- /dev/null +++ b/apps/web/models/Subscriber.ts @@ -0,0 +1,16 @@ +import mongoose from "mongoose"; + +export interface Subscriber { + subscriberId: string; + name?: string; + email: string; +} + +const SubscriberSchema = new mongoose.Schema({ + subscriberId: { type: String, required: true, unique: true }, + name: { type: String }, + email: { type: String, required: true, unique: true }, +}); + +export default mongoose.models.Subscriber || + mongoose.model("Subscriber", SubscriberSchema); diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 830fb594c..36a4fe488 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// /// // NOTE: This file should not be edited diff --git a/apps/web/package.json b/apps/web/package.json index d2b8c9925..7c7607c45 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,7 @@ "@courselit/page-primitives": "workspace:^", "@courselit/utils": "workspace:^", "@hookform/resolvers": "^3.9.1", - "@radix-ui/react-alert-dialog": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.1.11", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -33,7 +33,7 @@ "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", diff --git a/apps/web/ui-config/constants.ts b/apps/web/ui-config/constants.ts index 750f56fd9..615dae446 100644 --- a/apps/web/ui-config/constants.ts +++ b/apps/web/ui-config/constants.ts @@ -1,6 +1,7 @@ /** * This file provides application wide constants. */ +import { UIConstants } from "@courselit/common-models"; // Constants that represent types from the server export const LESSON_TYPE_TEXT = "text"; @@ -47,3 +48,19 @@ export const TIME_RANGES = [ { value: "1y", label: "1 year" }, { value: "lifetime", label: "Lifetime" }, ]; + +const { permissions } = UIConstants; +export const REQUIRED_PERMISSIONS_FOR_SETUP_CHECKLIST = [ + permissions.manageAnyCourse, + permissions.manageSettings, + permissions.manageSite, +] as const; + +export const ADMIN_PERMISSIONS = [ + UIConstants.permissions.manageAnyCourse, + UIConstants.permissions.manageCourse, + UIConstants.permissions.manageUsers, + UIConstants.permissions.manageSite, + UIConstants.permissions.manageCommunity, + UIConstants.permissions.manageSettings, +]; diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts index 98048dbea..477b98cad 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -596,7 +596,7 @@ export const APP_MESSAGE_MAIL_DELETED = "Mail deleted"; export const NEW_PAGE_FORM_WARNING = "These settings cannot be changed later on, so proceed with caution."; export const DASHBOARD_PAGE_HEADER = "Welcome"; -export const UNNAMED_USER = "Unnamed"; +export const UNNAMED_USER = "Stranger"; export const MAIL_REQUEST_FORM_REASON_FIELD = "Reason"; export const MAIL_REQUEST_FORM_REASON_PLACEHOLDER = "Please be as detailed as possible. This will help us review your application better."; @@ -646,3 +646,4 @@ export const HEADER_HELP = "Help"; export const CHECKOUT_PAGE_ORDER_SUMMARY = "Order summary"; export const TEXT_EDITOR_PLACEHOLDER = "Type here..."; export const BTN_VIEW_CERTIFICATE = "View certificate"; +export const GET_SET_UP = "Get set up"; diff --git a/apps/web/ui-lib/utils.ts b/apps/web/ui-lib/utils.ts index bd642a35c..b1ae80e08 100644 --- a/apps/web/ui-lib/utils.ts +++ b/apps/web/ui-lib/utils.ts @@ -13,8 +13,6 @@ import type { } from "@courselit/common-models"; import { checkPermission, FetchBuilder } from "@courselit/utils"; import { Constants, UIConstants } from "@courselit/common-models"; -import { createHash, randomInt } from "crypto"; -// import { headers as headersType } from "next/headers"; import { Theme } from "@courselit/page-models"; export { getPlanPrice } from "@courselit/utils"; const { permissions } = UIConstants; @@ -294,17 +292,6 @@ export const moveMemberUp = (arr: any[], index: number) => export const moveMemberDown = (arr: any[], index: number) => swapMembers(arr, index, index + 1); -export function generateUniquePasscode() { - return randomInt(100000, 999999); -} - -// Inspired from: https://github.com/nextauthjs/next-auth/blob/c4ad77b86762b7fd2e6362d8bf26c5953846774a/packages/next-auth/src/core/lib/utils.ts#L16 -export function hashCode(code: number) { - return createHash("sha256") - .update(`${code}${process.env.AUTH_SECRET}`) - .digest("hex"); -} - export const sortCourseGroups = (course: Course) => { return course.groups.sort((a: Group, b: Group) => a.rank - b.rank); }; diff --git a/packages/common-models/src/profile.ts b/packages/common-models/src/profile.ts index 25332a8c6..a85a9aa93 100644 --- a/packages/common-models/src/profile.ts +++ b/packages/common-models/src/profile.ts @@ -2,14 +2,14 @@ import { Media } from "./media"; import { Progress } from "./progress"; export default interface Profile { - name: string; + name?: string; id: string; fetched: boolean; purchases: Progress[]; email: string; - bio: string; + bio?: string; permissions: string[]; userId: string; - subscribedToUpdates: string; + subscribedToUpdates: boolean; avatar: Partial; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d227828be..5e622b3b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,7 +219,7 @@ importers: specifier: ^3.9.1 version: 3.10.0(react-hook-form@7.56.1(react@18.3.1)) '@radix-ui/react-alert-dialog': - specifier: ^1.1.2 + specifier: ^1.1.11 version: 1.1.11(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-avatar': specifier: ^1.1.3 @@ -258,7 +258,7 @@ importers: specifier: ^1.1.2 version: 1.1.4(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': - specifier: ^1.1.2 + specifier: ^1.2.3 version: 1.2.3(@types/react@18.3.20)(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.1.3