diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index a799a33da..d66f5ee83 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -30,9 +30,9 @@ jobs: - run: npm run build || true - run: | (HOST= npm start & bash ../../test/waitForServerStartup.sh) && ( \ - (echo "=========== Test attempt 1 ===========" && npx mocha --no-config --timeout 80000 test/**/*.test.js) || \ - (echo "=========== Test attempt 2 ===========" && npx mocha --no-config --timeout 80000 test/**/*.test.js) || \ - (echo "=========== Test attempt 3 ===========" && npx mocha --no-config --timeout 80000 test/**/*.test.js) \ + (echo "=========== Test attempt 1 ===========" && npx mocha --no-config --timeout 80000 test/**/*.{test.js,test.cjs}) || \ + (echo "=========== Test attempt 2 ===========" && npx mocha --no-config --timeout 80000 test/**/*.{test.js,test.cjs}) || \ + (echo "=========== Test attempt 3 ===========" && npx mocha --no-config --timeout 80000 test/**/*.{test.js,test.cjs}) \ ) - name: The job has failed if: ${{ failure() }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3600f9f59..7fad1c6f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +## [unreleased] + +## Added + +A demo Remix app for the third-party email password recipe to the `examples` folder. Also included is a basic test for the example app + ## [0.38.0] - 2024-02-29 ## Breaking Changes diff --git a/examples/with-remix-thirdpartyemailpassword/.eslintrc.cjs b/examples/with-remix-thirdpartyemailpassword/.eslintrc.cjs new file mode 100644 index 000000000..224f17fe0 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/.eslintrc.cjs @@ -0,0 +1,79 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: ["plugin:@typescript-eslint/recommended", "plugin:import/recommended", "plugin:import/typescript"], + }, + + // Node + { + files: [".eslintrc.js"], + env: { + node: true, + }, + }, + ], +}; diff --git a/examples/with-remix-thirdpartyemailpassword/.gitignore b/examples/with-remix-thirdpartyemailpassword/.gitignore new file mode 100644 index 000000000..28037e19a --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/.gitignore @@ -0,0 +1,3 @@ +/.cache +/build +/public/build diff --git a/examples/with-remix-thirdpartyemailpassword/README.md b/examples/with-remix-thirdpartyemailpassword/README.md new file mode 100644 index 000000000..fc4abbfc2 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/README.md @@ -0,0 +1,91 @@ +![SuperTokens banner](https://raw.githubusercontent.com/supertokens/supertokens-logo/master/images/Artboard%20%E2%80%93%2027%402x.png) + +# SuperTokens ThirdPartyEmailPassword Remix Demo App + +This demo app demonstrates how to integrate SuperTokens into a Remix application. + +This SuperTokens/Remix integration achieves the following: + +- Initializes SuperTokens with frontend and backend configurations +- Creates a frontend route to handle authentication-related tasks +- Integrates the SuperTokens' pre-built login UI for secure user authentication +- Protects frontend routes to ensure only authenticated users can access certain areas of the application +- Exposes the SuperTokens authentication APIs used by frontend widgets + +## Project structure & Parameters + +```txt +📦with-remix-thirdpartyemailpassword +┣ 📂app +┃ ┣ 📂config +┃ ┃ ┣ 📜appInfo.tsx +┃ ┃ ┣ 📜backend.tsx +┃ ┃ ┗ 📜frontend.tsx +┃ ┣ 📂routes +┃ ┃ ┣ 📜_index.tsx +┃ ┃ ┣ 📜api.auth.$.tsx +┃ ┃ ┗ 📜auth.$.tsx +┃ ┣ 📜app.css +┃ ┣ 📜entry.server.tsx +┃ ┗ 📜root.tsx +┣ 📂assets +┃ ┣ 📂fonts +┃ ┣ 📂images +┣ 📂test +┃ ┗ 📜basic.test.cjs +┣ 📜package.json +┣ 📜remix.config.mjs +┗ 📜server.mjs +``` + +Let's explore the important files: + +| Directory/File | Description | +| -------------------- | ----------------------------------------------------------------------------------------------- | +| **app** | Contains configuration files and route files for your application. | +| | **config** | +| | Contains configuration files for your application. | +| | `appInfo.tsx` : Includes information about your application reused throughout the app. | +| | `backend.tsx` : Backend-related configuration, including settings for SuperTokens. | +| | `frontend.tsx` : Frontend configuration, including settings for SuperTokens. | +| | **routes** | +| | Contains route files for your application. | +| | `_index.tsx` : Represents the default route or landing page. | +| | `api.auth.$.tsx` : Handles authentication-related API endpoints. | +| | `auth.$.tsx` : Deals with authentication routes or components using SuperTokens. | +| | `entry.server.tsx` : Entry point for server-side rendering (SSR) setup. | +| | `root.tsx` : Root component of your application. | +| **test** | Contains test files for your application. | +| | `basic.test.cjs` : A basic test file, presumably for testing functionality in your application. | +| **remix.config.mjs** | Remix configuration file containing settings for your Remix application. | +| **server.mjs** | File responsible for server-side functionality. | + +## Run application locally + +Follow the steps outlined below to run the application locally: + +1. Change directory to the **with-remix-thirdpartyemailpassword** folder. + + ```shell + cd supertokens-auth-react/examples/with-remix-thirdpartyemailpassword + ``` + +2. Run the command below to install the project dependencies: + + ```shell + npm install + ``` + +3. Run the application with the command below: + + ```shell + npm run dev + ``` + +## Author + +Created with :heart: by the folks at supertokens.com. + +## License + +This project is licensed under the Apache 2.0 license. diff --git a/examples/with-remix-thirdpartyemailpassword/app/app.css b/examples/with-remix-thirdpartyemailpassword/app/app.css new file mode 100644 index 000000000..a1461c4ed --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/app.css @@ -0,0 +1,217 @@ +html { + height: 100%; +} + +.main { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +.appContainer { + font-family: Rubik, sans-serif; +} + +.appContainer * { + box-sizing: border-box; +} + +.bold400 { + font-variation-settings: "wght" 400; +} + +.bold500 { + font-variation-settings: "wght" 500; +} + +.bold600 { + font-variation-settings: "wght" 600; +} + +.homeContainer { + min-height: 100vh; + min-width: 100vw; + background: url("../assets/images/background.png"); + background-size: cover; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.bold700 { + font-variation-settings: "wght" 700; +} + +.mainContainer { + box-shadow: 0px 0px 60px 0px rgba(0, 0, 0, 0.16); + width: min(635px, calc(100% - 24px)); + border-radius: 16px; + margin-block-end: 159px; + background-color: #ffffff; +} + +.successTitle { + line-height: 1; + padding-block: 26px; + background-color: #e7ffed; + text-align: center; + color: #3eb655; + display: flex; + justify-content: center; + align-items: flex-end; + font-size: 20px; +} + +.successIcon { + margin-right: 8px; +} + +.innerContent { + padding-block: 48px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.userId { + position: relative; + padding: 14px 17px; + border-image-slice: 1; + width: min(430px, calc(100% - 30px)); + margin-inline: auto; + margin-block: 11px 23px; + border-radius: 9px; + line-height: 1; + font-family: Menlo, serif; + cursor: text; +} + +.userId:before { + content: ""; + position: absolute; + inset: 0; + border-radius: 9px; + padding: 2px; + background: linear-gradient(90.31deg, #ff9933 0.11%, #ff3f33 99.82%); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +.topBand, +.bottomBand { + border-radius: inherit; +} + +.topBand { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.bottomBand { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.sessionButton { + padding-left: 13px; + padding-right: 13px; + padding-top: 8px; + padding-bottom: 8px; + cursor: pointer; + color: white; + font-weight: bold; + font-size: 17px; + + box-sizing: border-box; + background: #ff9933; + border: 1px solid #ff8a15; + box-shadow: 0px 3px 6px rgba(255, 153, 51, 0.16); + border-radius: 6px; + font-size: 16px; +} + +.bottomCTAContainer { + display: flex; + justify-content: flex-end; + padding-inline: 21px; + background-color: #212d4f; +} + +.viewCode { + padding-block: 11px; + color: #bac9f5; + cursor: pointer; + font-size: 14px; +} + +.bottomLinksContainer { + display: grid; + grid-template-columns: repeat(4, auto); + margin-bottom: 22px; +} + +.linksContainerLink { + display: flex; + align-items: center; + margin-inline-end: 68px; + cursor: pointer; + text-decoration: none; + color: #000000; +} + +.signOutLink { + border: 0; +} + +.linksContainerLink:last-child { + margin-right: 0; +} + +.truncate { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.separatorLine { + max-width: 100%; +} + +.linkIcon { + width: 15px; + margin-right: 5px; +} + +@media screen and (max-width: 768px) { + .bottomLinksContainer { + grid-template-columns: repeat(2, 1fr); + column-gap: 64px; + row-gap: 34px; + } + + .linksContainerLink { + margin-inline-end: 0; + } + + .separatorLine { + max-width: 200px; + } +} + +@media screen and (max-width: 480px) { + .homeContainer { + justify-content: start; + padding-block-start: 25px; + } + + .mainContainer { + margin-block-end: 90px; + } +} diff --git a/examples/with-remix-thirdpartyemailpassword/app/components/sessionAuthForRemix.tsx b/examples/with-remix-thirdpartyemailpassword/app/components/sessionAuthForRemix.tsx new file mode 100644 index 000000000..ccc4baf1b --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/components/sessionAuthForRemix.tsx @@ -0,0 +1,18 @@ +import { useState, useEffect } from "react"; +import { SessionAuth } from "supertokens-auth-react/recipe/session/index.js"; + +type Props = Parameters[0]; + +export const SessionAuthForRemix = (props: Props) => { + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + setLoaded(true); + }, []); + + if (!loaded) { + return props.children; + } + + return {props.children}; +}; diff --git a/examples/with-remix-thirdpartyemailpassword/app/components/tryRefreshClientComponent.tsx b/examples/with-remix-thirdpartyemailpassword/app/components/tryRefreshClientComponent.tsx new file mode 100644 index 000000000..c29f2ccb7 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/components/tryRefreshClientComponent.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "@remix-run/react"; +import Session from "supertokens-auth-react/recipe/session/index.js"; +import SuperTokens from "supertokens-auth-react"; + +export const TryRefreshComponent = () => { + // Get the navigation function from Remix + const navigate = useNavigate(); + // State to track if an error occurred during session refresh + const [didError, setDidError] = useState(false); + + // Effect to attempt refreshing the session when the component mounts + useEffect(() => { + void Session.attemptRefreshingSession() + .then((hasSession) => { + if (hasSession) { + navigate(window.location.pathname); + } else { + SuperTokens.redirectToAuth(); + } + }) + .catch(() => { + setDidError(true); + }); + }, [navigate]); + + if (didError) { + return
Something went wrong, please reload the page
; + } + return
Loading...
; +}; diff --git a/examples/with-remix-thirdpartyemailpassword/app/config/appInfo.tsx b/examples/with-remix-thirdpartyemailpassword/app/config/appInfo.tsx new file mode 100644 index 000000000..4ba4b432f --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/config/appInfo.tsx @@ -0,0 +1,8 @@ +export const appInfo = { + // learn more about this on https://supertokens.com/docs/thirdpartyemailpassword/appinfo + appName: "supertokens_remix_client", + apiDomain: "http://localhost:3000", + websiteDomain: "http://localhost:3000", + apiBasePath: "/api/auth", + websiteBasePath: "/auth", +}; diff --git a/examples/with-remix-thirdpartyemailpassword/app/config/backend.tsx b/examples/with-remix-thirdpartyemailpassword/app/config/backend.tsx new file mode 100644 index 000000000..985858cea --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/config/backend.tsx @@ -0,0 +1,90 @@ +import ThirdPartyEmailPasswordNode from "supertokens-node/recipe/thirdpartyemailpassword/index.js"; +import SessionNode from "supertokens-node/recipe/session/index.js"; +import Dashboard from "supertokens-node/recipe/dashboard/index.js"; +import UserRoles from "supertokens-node/recipe/userroles/index.js"; +import type { TypeInput } from "supertokens-node/types"; +import SuperTokens from "supertokens-node"; +import { appInfo } from "./appInfo"; +import EmailVerification from "supertokens-node/recipe/emailverification/index.js"; + +export const backendConfig = (): TypeInput => { + return { + supertokens: { + // this is the location of the SuperTokens core. + connectionURI: "https://try.supertokens.com", + }, + appInfo: appInfo, + recipeList: [ + ThirdPartyEmailPasswordNode.init({ + providers: [ + { + config: { + thirdPartyId: "google", + clients: [ + { + clientId: + "1060725074195-kmeum4crr01uirfl2op9kd5acmi9jutn.apps.googleusercontent.com", + clientSecret: "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", + }, + ], + }, + }, + { + config: { + thirdPartyId: "github", + clients: [ + { + clientId: "af29b1f7e3b61dde66fc", + clientSecret: "616563e386772674e34a8b10b226403d11a30752", + }, + ], + }, + }, + { + config: { + thirdPartyId: "apple", + clients: [ + { + clientId: "4398792-io.supertokens.example.service", + additionalConfig: { + keyId: "7M48Y4RYDL", + privateKey: + "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgu8gXs+XYkqXD6Ala9Sf/iJXzhbwcoG5dMh1OonpdJUmgCgYIKoZIzj0DAQehRANCAASfrvlFbFCYqn3I2zeknYXLwtH30JuOKestDbSfZYxZNMqhF/OzdZFTV0zc5u5s3eN+oCWbnvl0hM+9IW0UlkdA\n-----END PRIVATE KEY-----", + teamId: "YWQCXGJRJL", + }, + }, + ], + }, + }, + { + config: { + thirdPartyId: "twitter", + clients: [ + { + clientId: "4398792-WXpqVXRiazdRMGNJdEZIa3RVQXc6MTpjaQ", + clientSecret: "BivMbtwmcygbRLNQ0zk45yxvW246tnYnTFFq-LH39NwZMxFpdC", + }, + ], + }, + }, + ], + }), + EmailVerification.init({ + mode: "REQUIRED", + }), + SessionNode.init(), + Dashboard.init(), + UserRoles.init(), + ], + isInServerlessEnv: true, + framework: "custom", + }; +}; + +let initialized = false; +export function ensureSuperTokensInit() { + if (!initialized) { + SuperTokens.init(backendConfig()); + initialized = true; + } +} diff --git a/examples/with-remix-thirdpartyemailpassword/app/config/frontend.tsx b/examples/with-remix-thirdpartyemailpassword/app/config/frontend.tsx new file mode 100644 index 000000000..3db1cd90b --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/config/frontend.tsx @@ -0,0 +1,29 @@ +// This file is responsible for bootstrapping your Remix application on the client-side. It typically imports the necessary dependencies and initializes the client-side rendering environment. In this file, you might initialize client-side libraries, set up event listeners, or perform any other client-specific initialization tasks. It's the starting point for client-side code execution. + +import ThirdPartyEmailPasswordReact from "supertokens-auth-react/recipe/thirdpartyemailpassword/index.js"; +import Session from "supertokens-auth-react/recipe/session/index.js"; +import { appInfo } from "./appInfo"; +import { SuperTokensConfig } from "supertokens-auth-react/lib/build/types"; +import EmailVerification from "supertokens-auth-react/recipe/emailverification/index.js"; + +export const frontendConfig = (): SuperTokensConfig => { + return { + appInfo, + recipeList: [ + ThirdPartyEmailPasswordReact.init({ + signInAndUpFeature: { + providers: [ + ThirdPartyEmailPasswordReact.Google.init(), + ThirdPartyEmailPasswordReact.Github.init(), + ThirdPartyEmailPasswordReact.Apple.init(), + ], + }, + }), + EmailVerification.init(), + Session.init(), + ], + }; +}; +export const recipeDetails = { + docsLink: "https://supertokens.com/docs/thirdpartyemailpassword/introduction", +}; diff --git a/examples/with-remix-thirdpartyemailpassword/app/entry.server.tsx b/examples/with-remix-thirdpartyemailpassword/app/entry.server.tsx new file mode 100644 index 000000000..2fc56d1e5 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/entry.server.tsx @@ -0,0 +1,24 @@ +// This file is responsible for bootstrapping your Remix application on the server-side. It sets up the server environment, handles incoming requests, and renders the initial HTML markup to send back to the client. It typically imports the necessary server-side dependencies and sets up routing and middleware. This file is executed on the server when handling requests. + +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; +import { ensureSuperTokensInit } from "./config/backend"; + +ensureSuperTokensInit(); + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + const markup = renderToString(); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/examples/with-remix-thirdpartyemailpassword/app/lib/authAPIRequestHandler.ts b/examples/with-remix-thirdpartyemailpassword/app/lib/authAPIRequestHandler.ts new file mode 100644 index 000000000..697f13eb2 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/lib/authAPIRequestHandler.ts @@ -0,0 +1,41 @@ +import { middleware, PreParsedRequest, CollectingResponse } from "supertokens-node/lib/build/framework/custom/index.js"; +import { serialize } from "cookie"; +// import { HTTPMethod, PartialRemixRequest } from '../lib/superTokensTypes' + +export default function handleAuthAPIRequest(NextResponse: typeof Response) { + const stMiddleware = middleware((req) => { + return req; + }); + + return async function handleCall(req: T) { + const baseResponse = new CollectingResponse(); + + const { handled, error } = await stMiddleware(req, baseResponse); + + if (error) { + throw error; + } + if (!handled) { + return new NextResponse("Not found", { status: 404 }); + } + + for (const respCookie of baseResponse.cookies) { + baseResponse.headers.append( + "Set-Cookie", + serialize(respCookie.key, respCookie.value, { + domain: respCookie.domain, + expires: new Date(respCookie.expires), + httpOnly: respCookie.httpOnly, + path: respCookie.path, + sameSite: respCookie.sameSite, + secure: respCookie.secure, + }) + ); + } + + return new NextResponse(baseResponse.body, { + headers: baseResponse.headers, + status: baseResponse.statusCode, + }); + }; +} diff --git a/examples/with-remix-thirdpartyemailpassword/app/lib/superTokensHelpers.ts b/examples/with-remix-thirdpartyemailpassword/app/lib/superTokensHelpers.ts new file mode 100644 index 000000000..9220f6f14 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/lib/superTokensHelpers.ts @@ -0,0 +1,121 @@ +import { PreParsedRequest, CollectingResponse } from "supertokens-node/framework/custom/index.js"; +import { HTTPMethod } from "supertokens-node/types"; +import Session, { SessionContainer, VerifySessionOptions } from "supertokens-node/lib/build/recipe/session/index.js"; +import SessionRecipe from "supertokens-node/lib/build/recipe/session/recipe.js"; +import { availableTokenTransferMethods } from "supertokens-node/lib/build/recipe/session/constants.js"; +import { getToken } from "supertokens-node/lib/build/recipe/session/cookieAndHeaders.js"; +import { parseJWTWithoutSignatureVerification } from "supertokens-node/lib/build/recipe/session/jwt.js"; + +export function getCookieFromRequest(request: Request) { + const cookies: Record = {}; + const cookieHeader = request.headers.get("Cookie"); + if (cookieHeader) { + const cookieStrings = cookieHeader.split(";"); + for (const cookieString of cookieStrings) { + const [name, value] = cookieString.trim().split("="); + cookies[name] = decodeURIComponent(value); + } + } + return cookies; +} + +export function getQueryFromRequest(request: Request) { + const query: Record = {}; + const url = new URL(request.url); + const searchParams = url.searchParams; + searchParams.forEach((value, key) => { + query[key] = value; + }); + return query; +} +export function createPreParsedRequest(request: Request): PreParsedRequest { + return new PreParsedRequest({ + cookies: getCookieFromRequest(request), + url: request.url as string, + method: request.method as HTTPMethod, + query: getQueryFromRequest(request), + headers: request.headers, + getFormBody: async () => { + return await request.formData(); + }, + getJSONBody: async () => { + return await request.json(); + }, + }); +} + +export async function getSessionDetails( + request: Request, + options?: VerifySessionOptions, + userContext?: Record +): Promise<{ + session: SessionContainer | undefined; + hasToken: boolean; + hasInvalidClaims: boolean; + nextResponse?: Response; +}> { + console.log("Getting session details..."); + + const baseRequest = createPreParsedRequest(request); + console.log("Pre-parsed request created."); + + const baseResponse = new CollectingResponse(); + console.log("Collecting response object created."); + + // Possible introp issue. + const recipe = (SessionRecipe as any).default.instance; + console.log("Session recipe instance obtained."); + + const tokenTransferMethod = recipe.config.getTokenTransferMethod({ + req: baseRequest, + forCreateNewSession: false, + userContext, + }); + console.log("Token transfer method determined:", tokenTransferMethod); + + const transferMethods = tokenTransferMethod === "any" ? availableTokenTransferMethods : [tokenTransferMethod]; + console.log("Available token transfer methods:", transferMethods); + + const hasToken = transferMethods.some((transferMethod) => { + const token = getToken(baseRequest, "access", transferMethod); + if (!token) { + console.log("Token not found for transfer method:", transferMethod); + return false; + } + try { + parseJWTWithoutSignatureVerification(token); + console.log("Token parsed successfully."); + return true; + } catch { + console.log("Failed to parse token:", token); + return false; + } + }); + console.log("Does the user have a token?", hasToken); + + try { + const session = await Session.getSession(baseRequest, baseResponse, options, userContext); + console.log("Session obtained:", session); + return { + session, + hasInvalidClaims: false, + hasToken, + }; + } catch (err) { + console.error("Error while getting session:", err); + if (Session.Error.isErrorFromSuperTokens(err)) { + console.log("SuperTokens error detected."); + return { + hasToken, + hasInvalidClaims: err.type === Session.Error.INVALID_CLAIMS, + session: undefined, + nextResponse: new Response("Authentication required", { + status: err.type === Session.Error.INVALID_CLAIMS ? 403 : 401, + }), + }; + } else { + console.error("Unknown error occurred:", err); + throw err; + } + } +} diff --git a/examples/with-remix-thirdpartyemailpassword/app/lib/superTokensTypes.ts b/examples/with-remix-thirdpartyemailpassword/app/lib/superTokensTypes.ts new file mode 100644 index 000000000..5d72c535e --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/lib/superTokensTypes.ts @@ -0,0 +1,8 @@ +export type HTTPMethod = "post" | "get" | "delete" | "put" | "options" | "trace"; + +export interface SessionDataForUI { + note: string; + userId: string; + sessionHandle: string; + accessTokenPayload: object; +} diff --git a/examples/with-remix-thirdpartyemailpassword/app/root.tsx b/examples/with-remix-thirdpartyemailpassword/app/root.tsx new file mode 100644 index 000000000..36f07c616 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/root.tsx @@ -0,0 +1,41 @@ +import { Meta, Links, Scripts, LiveReload, Outlet, ScrollRestoration } from "@remix-run/react"; +import SuperTokens, { SuperTokensWrapper } from "supertokens-auth-react"; +import { frontendConfig } from "./config/frontend"; +import { SessionAuth } from "supertokens-auth-react/recipe/session/index.js"; +import { useLocation } from "react-router-dom"; +import type { LinksFunction } from "@remix-run/node"; +import appStylesHref from "./app.css"; +export const links: LinksFunction = () => [{ rel: "stylesheet", href: appStylesHref }]; + +if (typeof window !== "undefined") { + SuperTokens.init(frontendConfig()); +} + +export default function App() { + const location = useLocation(); + const isUnprotectedRoute = location.pathname.startsWith("/auth"); + + return ( + + + + + + + + {isUnprotectedRoute ? ( + + ) : ( + + + + )} + + + + + + + + ); +} diff --git a/examples/with-remix-thirdpartyemailpassword/app/routes/_index.tsx b/examples/with-remix-thirdpartyemailpassword/app/routes/_index.tsx new file mode 100644 index 000000000..5cfe26d72 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/routes/_index.tsx @@ -0,0 +1,192 @@ +import { CelebrateIcon, SeparatorLine, BlogsIcon, GuideIcon, SignOutIcon } from "../../assets/images"; +import { signOut } from "supertokens-auth-react/recipe/thirdpartyemailpassword/index.js"; +import { recipeDetails } from "../config/frontend"; +import SuperTokens from "supertokens-auth-react"; +import { LoaderFunctionArgs, redirect } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { SessionDataForUI } from "../lib/superTokensTypes"; +import { getSessionDetails } from "../lib/superTokensHelpers"; +import { TryRefreshComponent } from "../components/tryRefreshClientComponent"; +import { SessionAuthForRemix } from "../components/sessionAuthForRemix"; + +interface SessionForRemixProps { + session?: { + userId?: string; + sessionHandle?: string; + accessTokenPayload: SessionDataForUI; + }; + hasInvalidClaims: boolean; + hasToken: boolean; +} + +export async function loader({ request }: LoaderFunctionArgs): Promise<{ + session: SessionForRemixProps | undefined; + hasInvalidClaims: boolean; + hasToken: boolean; + nextResponse: Response | null; +}> { + try { + const { session, hasInvalidClaims, hasToken, nextResponse } = await getSessionDetails(request); + console.log("does the user have invalid claims?", hasInvalidClaims); + console.log("does the user have an access token??", hasToken); + if (session) { + console.log("there is an active session"); + } + if (!session) { + console.log("session does not exist or has expired"); + } + + const res: SessionForRemixProps = { + session: { + userId: session?.getUserId(), + sessionHandle: session?.getHandle(), + accessTokenPayload: session?.getAccessTokenPayload(), + }, + hasInvalidClaims, + hasToken, + }; + + console.log("\n\n", res, "\n\n"); + + if (nextResponse) { + console.log("nextResponse is:", nextResponse); + return { + // session, + session: res, + hasInvalidClaims, + hasToken, + nextResponse, + }; + } else { + console.log("nextResponse is null"); + return { + session: res, + hasInvalidClaims, + hasToken, + nextResponse: null, + }; + } + } catch (error) { + console.error("Error retrieving session:", error); + throw error; + } +} + +export default function Home() { + const loaderData = useLoaderData<{ + session: SessionForRemixProps | undefined; + hasInvalidClaims: boolean; + hasToken: boolean; + nextResponse: Response | null; + }>(); + + console.log(loaderData); + + if (loaderData.nextResponse) { + return ( +
+ Something went wrong while trying to get the session. Error -{loaderData.nextResponse.status}{" "} + {loaderData.nextResponse.statusText} +
+ ); + } + + if (!loaderData.session) { + if (!loaderData.hasToken) { + console.log("Redirecting to /auth"); + return redirect("/auth"); + } + if (loaderData.hasInvalidClaims) { + console.log("Session has invalid claims"); + return ; + } else { + console.log("Trying to refresh session"); + return ; + } + } + if (loaderData.session.session) { + const sessionData: SessionDataForUI = { + note: "Retrieve authenticated user-specific data from your application post-verification through the use of the verifySession middleware.", + userId: loaderData.session.session?.userId || "", + sessionHandle: loaderData.session.session?.accessTokenPayload.sessionHandle, + accessTokenPayload: loaderData.session.session?.accessTokenPayload, + }; + + const displaySessionInformationWindow = (sessionData: SessionDataForUI) => { + window.alert("Session Information: " + JSON.stringify(sessionData)); + }; + + const links: { + name: string; + link: string; + icon: string; + }[] = [ + { + name: "Blogs", + link: "https://supertokens.com/blog", + icon: BlogsIcon, + }, + { + name: "Guides", + link: recipeDetails.docsLink, + icon: GuideIcon, + }, + { + name: "Sign Out", + link: "", + icon: SignOutIcon, + }, + ]; + + return ( + +
+
+
+ Login successful + Login successful +
+
+
Your userID is:
+ +
{loaderData.session.session.userId}
+ + +
+
+ +
+ {links.map((link) => { + if (link.name === "Sign Out") { + return ( + + ); + } + return ( + + {link.name} +
{link.name}
+
+ ); + })} +
+ + separator +
+
+ ); + } +} diff --git a/examples/with-remix-thirdpartyemailpassword/app/routes/api.auth.$.tsx b/examples/with-remix-thirdpartyemailpassword/app/routes/api.auth.$.tsx new file mode 100644 index 000000000..d1f477c27 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/routes/api.auth.$.tsx @@ -0,0 +1,30 @@ +import { json } from "@remix-run/node"; +import { ActionFunctionArgs, LoaderFunctionArgs } from "react-router-dom"; +import handleAuthAPIRequest from "../lib/authAPIRequestHandler.js"; +import { createPreParsedRequest } from "../lib/superTokensHelpers.js"; + +const handleCall = handleAuthAPIRequest(Response); + +// Action function for handling POST requests +export async function action({ request }: ActionFunctionArgs) { + try { + const preParsedRequest = createPreParsedRequest(request); + const res = await handleCall(preParsedRequest); + return res; + } catch (error) { + return json({ error: "Internal server error" }, { status: 500 }); + } +} +// Loader function for handling GET requests that also adds cache control headers +export async function loader({ request }: LoaderFunctionArgs) { + try { + const preParsedRequest = createPreParsedRequest(request); + const res = await handleCall(preParsedRequest); + if (!res.headers.has("Cache-Control")) { + res.headers.set("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"); + } + return res; + } catch (error) { + return json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/examples/with-remix-thirdpartyemailpassword/app/routes/auth.$.tsx b/examples/with-remix-thirdpartyemailpassword/app/routes/auth.$.tsx new file mode 100644 index 000000000..0a62a01ba --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/app/routes/auth.$.tsx @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { redirectToAuth } from "supertokens-auth-react"; +import SuperTokens from "supertokens-auth-react/ui/index.js"; +import { ThirdPartyEmailPasswordPreBuiltUI } from "supertokens-auth-react/recipe/thirdpartyemailpassword/prebuiltui.js"; +import { EmailVerificationPreBuiltUI } from "supertokens-auth-react/recipe/emailverification/prebuiltui.js"; + +export const PreBuiltUIList = [ThirdPartyEmailPasswordPreBuiltUI, EmailVerificationPreBuiltUI]; + +export default function Auth() { + // If the user visits a page that is not handled by us (like /auth/random), then we redirect them back to the auth page. + const [loaded, setLoaded] = useState(false); + useEffect(() => { + if (SuperTokens.canHandleRoute(PreBuiltUIList) === false) { + redirectToAuth({ redirectBack: false }); + } else { + setLoaded(true); + } + }, []); + + if (loaded) { + return SuperTokens.getRoutingComponent(PreBuiltUIList); + } + + return null; +} diff --git a/examples/with-remix-thirdpartyemailpassword/assets/fonts/MenloRegular.ttf b/examples/with-remix-thirdpartyemailpassword/assets/fonts/MenloRegular.ttf new file mode 100644 index 000000000..033dc6d21 Binary files /dev/null and b/examples/with-remix-thirdpartyemailpassword/assets/fonts/MenloRegular.ttf differ diff --git a/examples/with-remix-thirdpartyemailpassword/assets/images/arrow-right-icon.svg b/examples/with-remix-thirdpartyemailpassword/assets/images/arrow-right-icon.svg new file mode 100644 index 000000000..95aa1fec6 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/assets/images/arrow-right-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-remix-thirdpartyemailpassword/assets/images/background.png b/examples/with-remix-thirdpartyemailpassword/assets/images/background.png new file mode 100644 index 000000000..2147c15c2 Binary files /dev/null and b/examples/with-remix-thirdpartyemailpassword/assets/images/background.png differ diff --git a/examples/with-remix-thirdpartyemailpassword/assets/images/blogs-icon.svg b/examples/with-remix-thirdpartyemailpassword/assets/images/blogs-icon.svg new file mode 100644 index 000000000..a2fc9dd62 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/assets/images/blogs-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-remix-thirdpartyemailpassword/assets/images/celebrate-icon.svg b/examples/with-remix-thirdpartyemailpassword/assets/images/celebrate-icon.svg new file mode 100644 index 000000000..3b40b1efa --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/assets/images/celebrate-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/examples/with-remix-thirdpartyemailpassword/assets/images/guide-icon.svg b/examples/with-remix-thirdpartyemailpassword/assets/images/guide-icon.svg new file mode 100644 index 000000000..bd85af72b --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/assets/images/guide-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-remix-thirdpartyemailpassword/assets/images/index.ts b/examples/with-remix-thirdpartyemailpassword/assets/images/index.ts new file mode 100644 index 000000000..7adf036c4 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/assets/images/index.ts @@ -0,0 +1,8 @@ +import SeparatorLine from "./separator-line.svg"; +import ArrowRight from "./arrow-right-icon.svg"; +import SignOutIcon from "./sign-out-icon.svg"; +import GuideIcon from "./guide-icon.svg"; +import BlogsIcon from "./blogs-icon.svg"; +import CelebrateIcon from "./celebrate-icon.svg"; + +export { SeparatorLine, ArrowRight, SignOutIcon, GuideIcon, BlogsIcon, CelebrateIcon }; diff --git a/examples/with-remix-thirdpartyemailpassword/assets/images/separator-line.svg b/examples/with-remix-thirdpartyemailpassword/assets/images/separator-line.svg new file mode 100644 index 000000000..7127a00dc --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/assets/images/separator-line.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/examples/with-remix-thirdpartyemailpassword/assets/images/sign-out-icon.svg b/examples/with-remix-thirdpartyemailpassword/assets/images/sign-out-icon.svg new file mode 100644 index 000000000..6cc4f85fd --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/assets/images/sign-out-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-remix-thirdpartyemailpassword/package.json b/examples/with-remix-thirdpartyemailpassword/package.json new file mode 100644 index 000000000..d6fce1b55 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/package.json @@ -0,0 +1,49 @@ +{ + "name": "supertokens-tpep-remix-scratch", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "remix build", + "dev": "remix dev", + "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "start": "remix-serve ./build/index.js", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/css-bundle": "^2.8.0", + "@remix-run/node": "^2.8.0", + "@remix-run/react": "^2.8.0", + "@remix-run/serve": "^2.8.0", + "cookie": "^0.6.0", + "cors": "^2.8.5", + "isbot": "^4.1.0", + "jsdom-global": "^3.0.2", + "puppeteer": "^22.4.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "supertokens-auth-react": "latest", + "supertokens-node": "latest" + }, + "devDependencies": { + "@remix-run/dev": "^2.8.0", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.7.4", + "@typescript-eslint/parser": "^6.7.4", + "eslint": "^8.38.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "mocha": "^10.3.0", + "typescript": "^5.1.6" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/with-remix-thirdpartyemailpassword/public/favicon.ico b/examples/with-remix-thirdpartyemailpassword/public/favicon.ico new file mode 100644 index 000000000..8830cf682 Binary files /dev/null and b/examples/with-remix-thirdpartyemailpassword/public/favicon.ico differ diff --git a/examples/with-remix-thirdpartyemailpassword/remix.config.mjs b/examples/with-remix-thirdpartyemailpassword/remix.config.mjs new file mode 100644 index 000000000..87c173cda --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/remix.config.mjs @@ -0,0 +1,13 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +export default { + ignoredRouteFiles: ["**/*.css"], + browserNodeBuiltinsPolyfill: { + modules: { punycode: true, zlib: true, querystring: true, util: true, buffer: true }, + globals: { + Buffer: true, + }, + }, + devServer: { + port: 3001, + }, +}; diff --git a/examples/with-remix-thirdpartyemailpassword/remix.env.d.ts b/examples/with-remix-thirdpartyemailpassword/remix.env.d.ts new file mode 100644 index 000000000..dcf8c45e1 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/with-remix-thirdpartyemailpassword/test/basic.test.cjs b/examples/with-remix-thirdpartyemailpassword/test/basic.test.cjs new file mode 100644 index 000000000..07a72a1c5 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/test/basic.test.cjs @@ -0,0 +1,126 @@ +/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/* + * Imports + */ + +const assert = require("assert"); +const puppeteer = require("puppeteer"); +const { + getTestEmail, + setInputValues, + submitForm, + toggleSignInSignUp, + waitForSTElement, +} = require("../../../test/exampleTestHelpers"); + +const SuperTokensNode = require("supertokens-node"); +const Session = require("supertokens-node/recipe/session"); +const EmailVerification = require("supertokens-node/recipe/emailverification"); +const EmailPassword = require("supertokens-node/recipe/emailpassword"); + +// Run the tests in a DOM environment. +require("jsdom-global")(); + +const apiDomain = "http://localhost:3000"; +const websiteDomain = "http://localhost:3000"; +SuperTokensNode.init({ + supertokens: { + // We are running these tests without running a local ST instance + connectionURI: "https://try.supertokens.com", + }, + appInfo: { + // These largely shouldn't matter except for creating links which we can change anyway + apiDomain: apiDomain, + websiteDomain: websiteDomain, + appName: "testNode", + }, + recipeList: [EmailVerification.init({ mode: "OPTIONAL" }), EmailPassword.init(), Session.init()], +}); + +describe("SuperTokens Example Basic tests", function () { + let browser; + let page; + const email = getTestEmail(); + const testPW = "Str0ngP@ssw0rd"; + + before(async function () { + browser = await puppeteer.launch({ + args: ["--no-sandbox", "--disable-setuid-sandbox"], + headless: true, + }); + page = await browser.newPage(); + }); + + after(async function () { + await browser.close(); + }); + + describe("Email Password test", function () { + it("Successful signup with credentials", async function () { + await Promise.all([page.goto(websiteDomain), page.waitForNavigation({ waitUntil: "networkidle0" })]); + + // redirected to /auth + await toggleSignInSignUp(page); + + await setInputValues(page, [ + { name: "email", value: email }, + { name: "password", value: testPW }, + ]); + await submitForm(page); + + // Redirected to email verification screen + + await waitForSTElement(page, "[data-supertokens~='sendVerifyEmailIcon']"); + + const userId = await page.evaluate(() => window.__supertokensSessionRecipe.getUserId()); + + // Attempt reloading Home + + await Promise.all([page.goto(websiteDomain), page.waitForNavigation({ waitUntil: "networkidle0" })]); + + await waitForSTElement(page, "[data-supertokens~='sendVerifyEmailIcon']"); + + // Create a new token and use it (we don't have access to the originally sent one) + const tokenInfo = await EmailVerification.createEmailVerificationToken( + "public", + SuperTokensNode.convertToRecipeUserId(userId), + email + ); + + await page.goto(`${websiteDomain}/auth/verify-email?token=${tokenInfo.token}`); + + await submitForm(page); + + const callApiBtn = await page.waitForSelector(".sessionButton"); + + let setAlertContent; + let alertContent = new Promise((res) => (setAlertContent = res)); + page.on("dialog", async (dialog) => { + setAlertContent(dialog.message()); + await dialog.dismiss(); + }); + + await callApiBtn.click(); + + const alertText = await alertContent; + + assert(alertText.startsWith("Session Information:")); + const sessionInfo = JSON.parse(alertText.replace(/^Session Information:/, "")); + assert.strictEqual(sessionInfo.userId, userId); + }); + }); +}); diff --git a/examples/with-remix-thirdpartyemailpassword/tsconfig.json b/examples/with-remix-thirdpartyemailpassword/tsconfig.json new file mode 100644 index 000000000..079425761 --- /dev/null +++ b/examples/with-remix-thirdpartyemailpassword/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/package.json b/package.json index 51b359a5a..108ca9c3a 100644 --- a/package.json +++ b/package.json @@ -217,5 +217,13 @@ "bugs": { "url": "https://github.com/supertokens/supertokens-auth-react/issues" }, - "homepage": "https://github.com/supertokens/supertokens-auth-react#readme" + "homepage": "https://github.com/supertokens/supertokens-auth-react#readme", + "types": "./index.d.ts", + "directories": { + "doc": "docs", + "example": "examples", + "lib": "lib", + "test": "test" + }, + "author": "" }