diff --git a/docs/.env.local.example b/docs/.env.local.example index 50ec3e8011..c7d5ecbafa 100644 --- a/docs/.env.local.example +++ b/docs/.env.local.example @@ -1,8 +1,30 @@ AUTH_SECRET= # Linux: `openssl rand -hex 32` or go to https://generate-secret.vercel.app/32 -AUTH_GITHUB_ID= -AUTH_GITHUB_SECRET= +# Better Auth Deployed URL +BETTER_AUTH_URL=http://localhost:3000 -# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. -# It's used for authentication when uploading source maps. -SENTRY_AUTH_TOKEN= +# ======= OPTIONAL ======= + +# # Polar Sandbox is used in dev mode: https://sandbox.polar.sh/ +# # You may need to delete your user in their dashboard if you get a "cannot attach new external ID error" +# POLAR_ACCESS_TOKEN= +# POLAR_WEBHOOK_SECRET= + +# # In production, we use postgres +# POSTGRES_URL= + +# # Email +# SMTP_HOST= +# SMTP_USER= +# SMTP_PASS= +# SMTP_PORT= +# # Insecure if false, secure if any other value +# SMTP_SECURE=false + +# # For GitHub Signin method +# AUTH_GITHUB_ID= +# AUTH_GITHUB_SECRET= + +# # The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. +# # It's used for authentication when uploading source maps. +# SENTRY_AUTH_TOKEN= diff --git a/docs/.gitignore b/docs/.gitignore index 1dd45b2022..af673ac194 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -37,3 +37,4 @@ next-env.d.ts # Sentry Config File .env.sentry-build-plugin +*.db \ No newline at end of file diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000000..0b113d1002 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,99 @@ +# Website Development + +To get started with development of the website, you can follow these steps: + +1. Initialize the DB + +If you haven't already, you can initialize the database with the following command: + +```bash +cd docs && pnpm run init-db +``` + +This will initialize an SQLite database at `./docs/sqlite.db`. + +2. Setup environment variables + +Copy the `.env.example` file to `.env.local` and set the environment variables. + +```bash +cp .env.example .env.local +``` + +If you want to test logging in, or payments see more information below [in the environment variables section](#environment-variables). + +3. Start the development server + +```bash +cd docs && pnpm run dev +``` + +This will start the development server on port 3000. + +## Environment Variables + +### Logging in + +To test logging in, you can set the following environment variables: + +```bash +AUTH_SECRET=test +# Github OAuth optionally +AUTH_GITHUB_ID=test +AUTH_GITHUB_SECRET=test +``` + +Note: the GITHUB_ID and GITHUB_SECRET are optional, but if you want to test logging in with Github you'll need to set them. For local development, you'll need to set the callback URL to `http://localhost:3000/api/auth/callback/github` + +### Payments + +To test payments, you can set the following environment variables: + +```bash +POLAR_ACCESS_TOKEN=test +POLAR_WEBHOOK_SECRET=test +``` + +For testing payments, you'll need access to the polar sandbox which needs to be configured to point a webhook to your local server. This can be configured at: + +You'll need something like [ngrok](https://ngrok.com/) to expose your local server to the internet. + +```bash +ngrok http http://localhost:3000 +``` + +You'll need the webhook to point to ngrok like so: + +``` +https://0000-00-00-000-00.ngrok-free.app/api/auth/polar/webhooks +``` + +With this webhook pointing to your local server, you should be able to test payments. + +### Email sending + +Note, this is not required, if email sending is not configured, the app will log the email it would send to the console. Often this is more convenient for development. + +To test email sending, you can set the following environment variables: + +```bash +SMTP_HOST= +SMTP_USER= +SMTP_PASS= +SMTP_PORT= +SMTP_SECURE=false +``` + +When configured, you'll be able to send emails to the email address you've configured. + +To setup with protonmail, you'll need to go to and create a new SMTP submission token. + +You'll need to set the following environment variables: + +```bash +SMTP_HOST=smtp.protonmail.com +SMTP_USER=my.email@protonmail.com +SMTP_PASS=my-smtp-token +SMTP_PORT=587 +SMTP_SECURE=false +``` diff --git a/docs/app/api/auth/[...all]/route.ts b/docs/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000000..a1953d1270 --- /dev/null +++ b/docs/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "../../../../auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler(auth); \ No newline at end of file diff --git a/docs/app/api/auth/[...nextauth]/route.ts b/docs/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 62da3d047a..0000000000 --- a/docs/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { GET, POST } from "../../../../auth"; -export const runtime = "edge"; // optional diff --git a/docs/app/portal/page.tsx b/docs/app/portal/page.tsx new file mode 100644 index 0000000000..5c97ff74e9 --- /dev/null +++ b/docs/app/portal/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useSession } from "@/util/auth-client"; + +// Just shows session info +export default function Me() { + const { data: session } = useSession(); + + if (!session) { + return
Not authenticated
; + } + + return ( +
+

Welcome {session.user.name}

+
{JSON.stringify(session, null, 2)}
+
+ ); +}; \ No newline at end of file diff --git a/docs/app/signin/page.tsx b/docs/app/signin/page.tsx new file mode 100644 index 0000000000..c05d02000a --- /dev/null +++ b/docs/app/signin/page.tsx @@ -0,0 +1,20 @@ +import { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { Suspense } from "react"; + +export const metadata: Metadata = { + title: "Login", +}; + +// dynamic import because we use search params in the client component +const AuthenticationPage = dynamic( + () => import("../../components/AuthenticationPage"), +); + +export default function Register() { + return ( + + + + ); +} diff --git a/docs/app/signin/password/page.tsx b/docs/app/signin/password/page.tsx new file mode 100644 index 0000000000..f03a0c7436 --- /dev/null +++ b/docs/app/signin/password/page.tsx @@ -0,0 +1,20 @@ +import { Metadata } from "next"; +import { Suspense } from "react"; +import dynamic from "next/dynamic"; + +export const metadata: Metadata = { + title: "Password Login", +}; + +// dynamic import because we use search params in the client component +const AuthenticationPage = dynamic( + () => import("../../../components/AuthenticationPage"), +); + +export default function Register() { + return ( + + + + ); +} diff --git a/docs/app/signup/page.tsx b/docs/app/signup/page.tsx new file mode 100644 index 0000000000..ca5495f0a1 --- /dev/null +++ b/docs/app/signup/page.tsx @@ -0,0 +1,20 @@ +import { Metadata } from "next"; +import { Suspense } from "react"; +import dynamic from "next/dynamic"; + +// dynamic import because we use search params in the client component +const AuthenticationPage = dynamic( + () => import("../../components/AuthenticationPage"), +); + +export const metadata: Metadata = { + title: "Sign-up", +}; + +export default function Register() { + return ( + + + + ); +} diff --git a/docs/auth.ts b/docs/auth.ts index b0e8d09cdf..b826681214 100644 --- a/docs/auth.ts +++ b/docs/auth.ts @@ -1,138 +1,277 @@ -import NextAuth from "next-auth"; -import GitHub from "next-auth/providers/github"; +import { polar } from "@polar-sh/better-auth"; +import { Polar } from "@polar-sh/sdk"; +import * as Sentry from "@sentry/nextjs"; +import { betterAuth } from "better-auth"; +import { createAuthMiddleware } from "better-auth/api"; +import { customSession, magicLink, openAPI } from "better-auth/plugins"; +import { github } from "better-auth/social-providers"; +import Database from "better-sqlite3"; +import { Pool } from "pg"; -export const { - handlers: { GET, POST }, - auth, - signIn, - signOut, -} = NextAuth({ - callbacks: { - signIn: async (params) => { - if (params.profile!.sponsorInfo) { - // user is sponsor - return true; - } - // user is signed in to github, but not a sponsor. - // TODO: We could redirect to pricing page here - return true; - // return "https://www.blocknotejs.org/pricing"; - }, - // https://authjs.dev/guides/extending-the-session - jwt({ token, user }) { - if (user) { - // User is available during sign-in - token.sponsorInfo = (user as any).sponsorInfo; - } - return token; +import { PRODUCTS } from "./util/product-list"; +import { sendEmail } from "./util/send-mail"; + +export const polarClient = new Polar({ + accessToken: process.env.POLAR_ACCESS_TOKEN, + // Use 'sandbox' if you're using the Polar Sandbox environment + // Remember that access tokens, products, etc. are completely separated between environments. + // Access tokens obtained in Production are for instance not usable in the Sandbox environment. + server: process.env.NODE_ENV === "production" ? "production" : "sandbox", +}); + +export const auth = betterAuth({ + user: { + additionalFields: { + planType: { + type: "string", + required: false, + input: false, // don't allow user to set plan type + }, + ghSponsorInfo: { + type: "string", + required: false, + input: false, // don't allow user to set role + }, }, - session: async (params) => { - (params.session.user as any).sponsorInfo = ( - params.token as any - ).sponsorInfo; - return params.session; + }, + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: true, + async sendVerificationEmail({ user, url }) { + await sendEmail({ + to: user.email, + template: "verifyEmail", + props: { url, name: user.name }, + }); }, }, - providers: [ - // copied from https://github.com/nextauthjs/next-auth/blob/234a150e2cac3bc62a9162fa00ed7cb16a105244/packages/core/src/providers/github.ts#L2, - // but with extra sponsorship api call - GitHub({ - userinfo: { - url: `https://api.github.com/user`, - async request({ tokens, provider }: any) { - const profile = await fetch(provider.userinfo?.url as URL, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - "User-Agent": "authjs", - }, - }).then(async (res) => await res.json()); - - if (!profile.email) { - // If the user does not have a public email, get another via the GitHub API - // See https://docs.github.com/en/rest/users/emails#list-public-email-addresses-for-the-authenticated-user - const res = await fetch(`https://api.github.com/user/emails`, { + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + autoSignIn: true, + }, + socialProviders: { + github: { + clientId: process.env.AUTH_GITHUB_ID as string, + clientSecret: process.env.AUTH_GITHUB_SECRET as string, + async getUserInfo(token) { + // This is a workaround to still re-use the default github provider getUserInfo + // and still be able to fetch the sponsor info with the token + return (await github({ + clientId: process.env.AUTH_GITHUB_ID as string, + clientSecret: process.env.AUTH_GITHUB_SECRET as string, + async mapProfileToUser() { + const resSponsor = await fetch(`https://api.github.com/graphql`, { + method: "POST", headers: { - Authorization: `Bearer ${tokens.access_token}`, - "User-Agent": "authjs", + "Content-Type": "application/json", + Authorization: `Bearer ${token.accessToken}`, }, + // organization(login:"TypeCellOS") { + // user(login:"YousefED") { + body: JSON.stringify({ + query: `{ + user(login:"YousefED") { + sponsorshipForViewerAsSponsor(activeOnly:false) { + isActive, + tier { + name + monthlyPriceInDollars + } + } + } + }`, + }), }); - if (res.ok) { - const emails: any[] = await res.json(); - profile.email = ( - emails.find((e) => e.primary) ?? emails[0] - ).email; - } - } + if (resSponsor.ok) { + // Mock data. TODO: disable and test actial data + // profile.sponsorInfo = { + // isActive: true, + // tier: { + // name: "test", + // monthlyPriceInDollars: 100, + // }, + // }; + // use API data: - const resSponsor = await fetch(`https://api.github.com/graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokens.access_token}`, - }, - // organization(login:"TypeCellOS") { - // user(login:"YousefED") { - body: JSON.stringify({ - query: `{ - user(login:"YousefED") { - sponsorshipForViewerAsSponsor(activeOnly:false) { - isActive, - tier { - name - monthlyPriceInDollars - } - } - } - }`, - }), - }); + const data = await resSponsor.json(); + // eslint-disable-next-line no-console + console.log("sponsor data", data); - if (resSponsor.ok) { - // Mock data. TODO: disable and test actial data - // profile.sponsorInfo = { - // isActive: true, - // tier: { - // name: "test", - // monthlyPriceInDollars: 100, - // }, - // }; - // use API data: + // { + // "data": { + // "user": { + // "sponsorshipForViewerAsSponsor": { + // "isActive": true, + // "tier": { + // "name": "$90 a month", + // "monthlyPriceInDollars": 90 + // } + // } + // } + // } + // } - const data = await resSponsor.json(); - // eslint-disable-next-line no-console - console.log("sponsor data", data); + const sponsorInfo: null | { + isActive: boolean; + tier: { + monthlyPriceInDollars: number; + }; + } = data.data.user.sponsorshipForViewerAsSponsor; - // { - // "data": { - // "user": { - // "sponsorshipForViewerAsSponsor": { - // "isActive": true, - // "tier": { - // "name": "$90 a month", - // "monthlyPriceInDollars": 90 - // } - // } - // } - // } - // } + if (!sponsorInfo?.isActive) { + return {}; + } - profile.sponsorInfo = data.data.user.sponsorshipForViewerAsSponsor; - } + return { + ghSponsorInfo: JSON.stringify(sponsorInfo), + }; + } - return profile; - }, + return {}; + }, + }).getUserInfo(token))!; }, - profile: (profile) => { + }, + }, + // Use SQLite for local development + database: + process.env.NODE_ENV === "production" || process.env.POSTGRES_URL + ? new Pool({ + connectionString: process.env.POSTGRES_URL, + }) + : new Database("./sqlite.db"), + plugins: [ + customSession( + async ({ user, session }) => { + // If they are a GitHub sponsor, use that plan type + if (user.ghSponsorInfo) { + const sponsorInfo = JSON.parse(user.ghSponsorInfo); + return { + planType: + sponsorInfo.tier.monthlyPriceInDollars > 100 + ? "business" + : "starter", + user, + session, + }; + } + // If not, see if they are subscribed to a Polar product + // If not, use the free plan return { - id: profile.id.toString(), - name: profile.name ?? profile.login, - email: profile.email, - image: profile.avatar_url, - username: profile.login, - sponsorInfo: profile.sponsorInfo, + planType: user.planType ?? PRODUCTS.free.slug, + user, + session, }; }, + { + // This is really only for type inference + user: { + additionalFields: { + ghSponsorInfo: { + type: "string", + required: false, + input: false, // don't allow user to set role + }, + planType: { + type: "string", + required: false, + input: false, // don't allow user to set plan type + }, + }, + }, + }, + ), + magicLink({ + sendMagicLink: async ({ email, url }) => { + await sendEmail({ + to: email, + template: "magicLink", + props: { url }, + }); + }, + }), + // Just temporary for testing + // Serves on http://localhost:3000/api/auth/reference + openAPI(), + polar({ + client: polarClient, + // Enable automatic Polar Customer creation on signup + createCustomerOnSignUp: true, + // http://localhost:3000/api/auth/portal + enableCustomerPortal: true, + // Configure checkout + checkout: { + enabled: true, + products: [ + { + productId: PRODUCTS.business.id, // ID of Product from Polar Dashboard + slug: PRODUCTS.business.slug, // Custom slug for easy reference in Checkout URL, e.g. /checkout/pro + // http://localhost:3000/api/auth/checkout/business + }, + { + productId: PRODUCTS.starter.id, + slug: PRODUCTS.starter.slug, + // http://localhost:3000/api/auth/checkout/starter + }, + ], + successUrl: "/thanks", + }, + // Incoming Webhooks handler will be installed at /polar/webhooks + webhooks: { + // webhooks have to be publicly accessible + // ngrok http http://localhost:3000 + secret: process.env.POLAR_WEBHOOK_SECRET as string, + onSubscriptionUpdated: async (payload) => { + const authContext = await auth.$context; + const userId = payload.data.customer.externalId; + if (!userId) { + return; + } + if (payload.data.status === "active") { + const productId = payload.data.product.id; + const planType = Object.values(PRODUCTS).find( + (p) => p.id === productId, + )?.slug; + await authContext.internalAdapter.updateUser(userId, { + planType, + }); + } else { + // No active subscription, so we need to remove the plan type + await authContext.internalAdapter.updateUser(userId, { + planType: null, + }); + } + }, + }, }), ], + onAPIError: { + onError: (error) => { + Sentry.captureException(error); + }, + }, + hooks: { + after: createAuthMiddleware(async (ctx) => { + if ( + ctx.path === "/magic-link/verify" || + ctx.path === "/verify-email" || + ctx.path === "/sign-in/social" + ) { + // After verifying email, send them a welcome email + const newSession = ctx.context.newSession; + if (newSession) { + await sendEmail({ + to: newSession.user.email, + template: "welcome", + props: { + name: newSession.user.name, + }, + }); + return; + } + } + }), + }, }); diff --git a/docs/components/AuthNavButton.tsx b/docs/components/AuthNavButton.tsx index 568e2249ea..b96f4803d8 100644 --- a/docs/components/AuthNavButton.tsx +++ b/docs/components/AuthNavButton.tsx @@ -1,44 +1,55 @@ +import { authClient } from "@/util/auth-client"; import { Menu, Transition } from "@headlessui/react"; import clsx from "clsx"; -import { signOut, useSession } from "next-auth/react"; -import Image from "next/image"; import { ReactElement, ReactNode } from "react"; +import { UserImage } from "./UserImage"; + export function AuthNavButton(props: any) { - const session = useSession(); + const session = authClient.useSession(); - return session.status === "authenticated" ? ( + return session.data ? ( -
+
+ 💖 Thanks for subscribing! 💖 +
+ + ), + + - 💖 Thanks for sponsoring! -
+ {session.data.planType === "free" + ? "Get BlockNote Pro" + : "Manage my subscription"} + , , ]}> - {session.data.user!.name!} +
) : ( <> diff --git a/docs/components/AuthenticationPage.tsx b/docs/components/AuthenticationPage.tsx new file mode 100644 index 0000000000..fb21aec47a --- /dev/null +++ b/docs/components/AuthenticationPage.tsx @@ -0,0 +1,419 @@ +"use client"; + +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + ChangeEvent, + FormEvent, + HTMLInputTypeAttribute, + ReactNode, + useEffect, + useState, +} from "react"; + +import { authClient, signIn, signUp } from "@/util/auth-client"; +import blockNoteLogo from "@/public/img/logos/banner.svg"; +import blockNoteLogoDark from "@/public/img/logos/banner.dark.svg"; + +function AuthenticationInput(props: { + type: HTMLInputTypeAttribute; + name: string; + onChange: (e: ChangeEvent) => void; +}) { + return ( +
+ +
+ +
+
+ ); +} + +function AuthenticationBox(props: { + variant: "password" | "register" | "email"; +}) { + const router = useRouter(); + + const searchParams = useSearchParams(); + const callbackURL = + decodeURIComponent(searchParams?.get("redirect") || "") || "/"; + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const [signingInState, setSigningInState] = useState< + | { state: "init" } + | { state: "loading" } + | { state: "done"; message: string } + | { state: "error"; message: string } + >({ state: "init" }); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + setSigningInState({ state: "loading" }); + + if (props.variant === "password") { + await signIn.email( + { + email, + password, + callbackURL, + }, + { + onSuccess() { + router.push(callbackURL || "/"); + }, + onError(ctx) { + setSigningInState({ + state: "error", + message: ctx.error.message || ctx.error.statusText || "", + }); + }, + }, + ); + } else if (props.variant === "email") { + await signIn.magicLink( + { + email, + callbackURL, + }, + { + onSuccess() { + setSigningInState({ + state: "done", + message: + "We've sent you an email. Click the link inside to log in.", + }); + }, + onError(ctx) { + if (ctx.error.code === "VALIDATION_ERROR") { + setSigningInState({ + state: "error", + message: "Invalid email address domain.", + }); + } else { + setSigningInState({ + state: "error", + message: ctx.error.message || ctx.error.statusText || "", + }); + } + }, + }, + ); + } else { + await signUp.email( + { + email, + password, + name, + callbackURL: "/pricing", + }, + { + onSuccess() { + setSigningInState({ + state: "done", + message: + "We've sent you an email. Click the link inside to verify your account.", + }); + }, + onError(ctx) { + if ( + ctx.error.code === + "POLAR_CUSTOMER_CREATION_FAILED_ERROR_API_ERROR_OCCURRED_DETAILLOCBODYEMAILMSGVALUE_IS_NOT_A_VALID_EMAIL_ADDRESS_THE_DOMAIN_NAME_FESDDDCOM_DOES_NOT_EXISTTYPEVALUE_ERROR" + ) { + setSigningInState({ + state: "error", + message: "Invalid email address domain.", + }); + } else { + setSigningInState({ + state: "error", + message: ctx.error.message || ctx.error.statusText || "", + }); + } + }, + }, + ); + } + }; + + if (signingInState.state === "done") { + return ( +
+ +

{signingInState.message}

+
+ ); + } + + return ( + <> +
+ {props.variant === "register" && ( + setName(e.target.value)} + /> + )} + setEmail(e.target.value)} + /> + {(props.variant === "password" || props.variant === "register") && ( + setPassword(e.target.value)} + /> + )} + + {signingInState.state === "error" && ( +

+ {signingInState.message} +

+ )} + + + + + ); +} + +function AlternativeSignInButton(props: { + name: string; + icon: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function EmailSignInButton() { + const router = useRouter(); + + const searchParams = useSearchParams(); + const callbackURL = searchParams?.get("redirect") || "/"; + const theme = searchParams?.get("theme") || "light"; + + return ( +