diff --git a/boilerplate/frontend/supertokens-react-custom/.gitignore b/boilerplate/frontend/supertokens-react-custom/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/boilerplate/frontend/supertokens-react-custom/README.md b/boilerplate/frontend/supertokens-react-custom/README.md new file mode 100644 index 00000000..b6897e3f --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ["./tsconfig.node.json", "./tsconfig.app.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, +}); +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from "eslint-plugin-react"; + +export default tseslint.config({ + // Set the react version + settings: { react: { version: "18.3" } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs["jsx-runtime"].rules, + }, +}); +``` diff --git a/boilerplate/frontend/supertokens-react-custom/eslint.config.js b/boilerplate/frontend/supertokens-react-custom/eslint.config.js new file mode 100644 index 00000000..e1770e4a --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/eslint.config.js @@ -0,0 +1,25 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + }, + } +); diff --git a/boilerplate/frontend/supertokens-react-custom/index.html b/boilerplate/frontend/supertokens-react-custom/index.html new file mode 100644 index 00000000..1f2712c1 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/index.html @@ -0,0 +1,13 @@ + + + + + + + Supertokens Demo App | React + + +
+ + + diff --git a/boilerplate/frontend/supertokens-react-custom/package.json b/boilerplate/frontend/supertokens-react-custom/package.json new file mode 100644 index 00000000..b3d7cb9c --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/package.json @@ -0,0 +1,38 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-icons": "^5.3.0", + "react-loader-spinner": "^6.1.6", + "react-router-dom": "^6.26.2", + "react-toastify": "^10.0.5", + "supertokens-web-js": "^0.13.1" + }, + "devDependencies": { + "@eslint/js": "^9.11.1", + "@types/node": "^22.7.5", + "@types/react": "^18.3.10", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "eslint": "^9.11.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.9.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.5.3", + "typescript-eslint": "^8.7.0", + "vite": "^5.4.8" + } +} diff --git a/boilerplate/frontend/supertokens-react-custom/postcss.config.js b/boilerplate/frontend/supertokens-react-custom/postcss.config.js new file mode 100644 index 00000000..49c0612d --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/boilerplate/frontend/supertokens-react-custom/public/favicon.ico b/boilerplate/frontend/supertokens-react-custom/public/favicon.ico new file mode 100644 index 00000000..d14dbd1d Binary files /dev/null and b/boilerplate/frontend/supertokens-react-custom/public/favicon.ico differ diff --git a/boilerplate/frontend/supertokens-react-custom/public/logo.webp b/boilerplate/frontend/supertokens-react-custom/public/logo.webp new file mode 100644 index 00000000..0ac5bd44 Binary files /dev/null and b/boilerplate/frontend/supertokens-react-custom/public/logo.webp differ diff --git a/boilerplate/frontend/supertokens-react-custom/src/App.tsx b/boilerplate/frontend/supertokens-react-custom/src/App.tsx new file mode 100644 index 00000000..a298fb5b --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/App.tsx @@ -0,0 +1,33 @@ +import SuperTokens from "supertokens-web-js"; +import { superTokensConfig } from "./config"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import HomePage from "@/pages/Home"; +import AuthRoutes from "@/pages/Auth"; +import DashboardPage from "@/pages/Dashboard"; +import Protected from "@/auth/Protected"; + +SuperTokens.init(superTokensConfig); + +const router = createBrowserRouter([ + { + path: "/", + element: , + }, + { + path: "/dashboard", + element: , + children: [ + { + index: true, + element: , + }, + ], + }, + ...AuthRoutes, +]); + +function App() { + return ; +} + +export default App; diff --git a/boilerplate/frontend/supertokens-react-custom/src/auth/Protected.tsx b/boilerplate/frontend/supertokens-react-custom/src/auth/Protected.tsx new file mode 100644 index 00000000..a93c8dcc --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/auth/Protected.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import Session from "supertokens-web-js/recipe/session"; + +export default function Protected() { + const [isSessionStatusLoading, setIsSessionStatusLoading] = useState(true); + const navigate = useNavigate(); + const location = useLocation(); + useEffect(() => { + async function checkSession() { + setIsSessionStatusLoading(true); + try { + const isSessionValid = await Session.doesSessionExist(); + if (!isSessionValid) { + navigate(`/authenticate?redirectTo=${location.pathname}`); + } + } catch (error) { + console.error(error); + } finally { + setIsSessionStatusLoading(false); + } + } + checkSession(); + }, []); + + if (isSessionStatusLoading) { + return
Loading...
; + } + return ( +
+ +
+ ); +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/components/Footer.tsx b/boilerplate/frontend/supertokens-react-custom/src/components/Footer.tsx new file mode 100644 index 00000000..fddf4d5b --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/components/Footer.tsx @@ -0,0 +1,7 @@ +interface FooterProps { + title?: string; +} + +export default function Footer({ title }: FooterProps) { + return
{title &&

{title}

}
; +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/components/Header.tsx b/boilerplate/frontend/supertokens-react-custom/src/components/Header.tsx new file mode 100644 index 00000000..bcb2e631 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/components/Header.tsx @@ -0,0 +1,11 @@ +interface HeaderProps { + title?: string; +} +export default function Header({ title }: HeaderProps) { + return ( +
+ Supertokens Logo + {title &&

{title}

} +
+ ); +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/components/Input.tsx b/boilerplate/frontend/supertokens-react-custom/src/components/Input.tsx new file mode 100644 index 00000000..0720fc97 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/components/Input.tsx @@ -0,0 +1,9 @@ +import React, { forwardRef } from "react"; + +type InputProps = React.InputHTMLAttributes; + +const Input = forwardRef((props, ref) => { + return ; +}); + +export default Input; diff --git a/boilerplate/frontend/supertokens-react-custom/src/components/Spinner.tsx b/boilerplate/frontend/supertokens-react-custom/src/components/Spinner.tsx new file mode 100644 index 00000000..6396b4ab --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/components/Spinner.tsx @@ -0,0 +1,17 @@ +import { ColorRing } from "react-loader-spinner"; + +export default function Spinner() { + return ( +
+ +
+ ); +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/config.ts b/boilerplate/frontend/supertokens-react-custom/src/config.ts new file mode 100644 index 00000000..3a41f738 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/config.ts @@ -0,0 +1,25 @@ +import EmailPassword from "supertokens-web-js/recipe/emailpassword"; +import Session from "supertokens-web-js/recipe/session"; +import Passwordless from "supertokens-web-js/recipe/passwordless"; +import ThirdParty from "supertokens-web-js/recipe/thirdparty"; + +export function getApiDomain() { + const apiPort = import.meta.env.VITE_APP_API_PORT || 3001; + const apiUrl = import.meta.env.VITE_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = import.meta.env.VITE_APP_WEBSITE_PORT || 5173; + const websiteUrl = import.meta.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const superTokensConfig = { + appInfo: { + apiDomain: "http://localhost:3001", + apiBasePath: "/auth", + appName: "Custom UI Demo", + }, + recipeList: [Session.init(), EmailPassword.init(), Passwordless.init(), ThirdParty.init()], +}; diff --git a/boilerplate/frontend/supertokens-react-custom/src/config/emailpassword.tsx b/boilerplate/frontend/supertokens-react-custom/src/config/emailpassword.tsx new file mode 100644 index 00000000..ecaa03a3 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/config/emailpassword.tsx @@ -0,0 +1,23 @@ +import EmailPassword from "supertokens-web-js/recipe/emailpassword"; +import Session from "supertokens-web-js/recipe/session"; + +export function getApiDomain() { + const apiPort = import.meta.env.VITE_APP_API_PORT || 3001; + const apiUrl = import.meta.env.VITE_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = import.meta.env.VITE_APP_WEBSITE_PORT || 3000; + const websiteUrl = import.meta.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const superTokensConfig = { + appInfo: { + apiBasePath: "/auth", + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + }, + recipeList: [Session.init(), EmailPassword.init()], +}; diff --git a/boilerplate/frontend/supertokens-react-custom/src/config/passwordless.tsx b/boilerplate/frontend/supertokens-react-custom/src/config/passwordless.tsx new file mode 100644 index 00000000..a540bb1a --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/config/passwordless.tsx @@ -0,0 +1,23 @@ +import Session from "supertokens-web-js/recipe/session"; +import Passwordless from "supertokens-web-js/recipe/passwordless"; + +export function getApiDomain() { + const apiPort = import.meta.env.VITE_APP_API_PORT || 3001; + const apiUrl = import.meta.env.VITE_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = import.meta.env.VITE_APP_WEBSITE_PORT || 3000; + const websiteUrl = import.meta.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const superTokensConfig = { + appInfo: { + apiBasePath: "/auth", + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + }, + recipeList: [Session.init(), Passwordless.init()], +}; diff --git a/boilerplate/frontend/supertokens-react-custom/src/config/thirdparty.tsx b/boilerplate/frontend/supertokens-react-custom/src/config/thirdparty.tsx new file mode 100644 index 00000000..299f3447 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/config/thirdparty.tsx @@ -0,0 +1,23 @@ +import Session from "supertokens-web-js/recipe/session"; +import ThirdParty from "supertokens-web-js/recipe/thirdparty"; + +export function getApiDomain() { + const apiPort = import.meta.env.VITE_APP_API_PORT || 3001; + const apiUrl = import.meta.env.VITE_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = import.meta.env.VITE_APP_WEBSITE_PORT || 3000; + const websiteUrl = import.meta.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const superTokensConfig = { + appInfo: { + apiBasePath: "/auth", + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + }, + recipeList: [Session.init(), ThirdParty.init()], +}; diff --git a/boilerplate/frontend/supertokens-react-custom/src/config/thirdpartyemailpassword.tsx b/boilerplate/frontend/supertokens-react-custom/src/config/thirdpartyemailpassword.tsx new file mode 100644 index 00000000..16a411c4 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/config/thirdpartyemailpassword.tsx @@ -0,0 +1,24 @@ +import Session from "supertokens-web-js/recipe/session"; +import ThirdParty from "supertokens-web-js/recipe/thirdparty"; +import EmailPassword from "supertokens-web-js/recipe/emailpassword"; + +export function getApiDomain() { + const apiPort = import.meta.env.VITE_APP_API_PORT || 3001; + const apiUrl = import.meta.env.VITE_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = import.meta.env.VITE_APP_WEBSITE_PORT || 3000; + const websiteUrl = import.meta.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const superTokensConfig = { + appInfo: { + apiBasePath: "/auth", + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + }, + recipeList: [Session.init(), ThirdParty.init(), EmailPassword.init()], +}; diff --git a/boilerplate/frontend/supertokens-react-custom/src/config/thirdpartypasswordless.tsx b/boilerplate/frontend/supertokens-react-custom/src/config/thirdpartypasswordless.tsx new file mode 100644 index 00000000..60250b35 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/config/thirdpartypasswordless.tsx @@ -0,0 +1,24 @@ +import Session from "supertokens-web-js/recipe/session"; +import ThirdParty from "supertokens-web-js/recipe/thirdparty"; +import Passwordless from "supertokens-web-js/recipe/passwordless"; + +export function getApiDomain() { + const apiPort = import.meta.env.VITE_APP_API_PORT || 3001; + const apiUrl = import.meta.env.VITE_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = import.meta.env.VITE_APP_WEBSITE_PORT || 3000; + const websiteUrl = import.meta.env.VITE_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const superTokensConfig = { + appInfo: { + apiBasePath: "/auth", + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + }, + recipeList: [Session.init(), ThirdParty.init(), Passwordless.init()], +}; diff --git a/boilerplate/frontend/supertokens-react-custom/src/hooks/useSessionInfo.ts b/boilerplate/frontend/supertokens-react-custom/src/hooks/useSessionInfo.ts new file mode 100644 index 00000000..82922240 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/hooks/useSessionInfo.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import Session from "supertokens-web-js/recipe/session"; + +type UserLoggedIn = + | { + isLoading: true; + } + | { + isLoading: false; + isLoggedIn: boolean; + }; + +function useSessionInfo() { + const [isUserLoggedIn, setIsUserLoggedIn] = useState({ + isLoading: true, + }); + useEffect(() => { + async function checkSession() { + try { + const isSessionValid = await Session.doesSessionExist(); + setIsUserLoggedIn({ + isLoading: false, + isLoggedIn: isSessionValid, + }); + } catch (error) { + console.error(error); + } + } + + checkSession(); + }, []); + return { + sessionExists: isUserLoggedIn, + }; +} + +export default useSessionInfo; diff --git a/boilerplate/frontend/supertokens-react-custom/src/index.css b/boilerplate/frontend/supertokens-react-custom/src/index.css new file mode 100644 index 00000000..76e7ab84 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + background-color: #121212; +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/main.tsx b/boilerplate/frontend/supertokens-react-custom/src/main.tsx new file mode 100644 index 00000000..ef2f2fe7 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; +import "./index.css"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; + +createRoot(document.getElementById("root")!).render( + + + + +); diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/emailpassword/LoginAndRegister.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/emailpassword/LoginAndRegister.tsx new file mode 100644 index 00000000..71ed630f --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/emailpassword/LoginAndRegister.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState } from "react"; +import useSessionInfo from "@/hooks/useSessionInfo"; +import { useNavigate } from "react-router-dom"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import Input from "@/components/Input"; +import { toast } from "react-toastify"; +import { signIn, signUp } from "supertokens-web-js/recipe/emailpassword"; +import STGeneralError from "supertokens-web-js/utils/error"; +import Spinner from "@/components/Spinner"; + +type AuthenticationType = "login" | "registration"; +interface FormInputState { + email: string; + password: string; + confirmPassword?: string; +} + +const initialFormInputState: FormInputState = { + email: "", + password: "", + confirmPassword: "", +}; + +interface LoginAndRegisterProps { + showHeader?: boolean; + showFooter?: boolean; + rootStyle?: React.CSSProperties; +} + +export default function LoginAndRegister({ showFooter = true, showHeader = true, rootStyle }: LoginAndRegisterProps) { + /** + * The authentication type state. This would be used to switch between login and registration. + */ + const [authenticationType, setAuthenticationType] = useState("login"); + + const navigation = useNavigate(); + const [formInputState, setFormInputState] = useState(initialFormInputState); + const [isLoading, setIsLoading] = useState(false); + + const { sessionExists } = useSessionInfo(); + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (isLoading) return; + if (authenticationType === "registration" && formInputState.password !== formInputState.confirmPassword) { + toast.error("Passwords do not match"); + return; + } + setIsLoading(true); + try { + /** + * User SignIn/Login flow + */ + if (authenticationType === "login") { + const response = await signIn({ + formFields: [ + { + id: "email", + value: formInputState.email, + }, + { + id: "password", + value: formInputState.password, + }, + ], + }); + + if (response.status === "FIELD_ERROR") { + for (const formField of response.formFields) { + throw new Error(formField.error); + } + } else if (response.status === "WRONG_CREDENTIALS_ERROR") { + throw new Error("Email password combination is incorrect."); + } else if (response.status === "SIGN_IN_NOT_ALLOWED") { + throw new Error(response.reason); + } else { + toast.success("Login Successful"); + navigation("/dashboard"); + return; + } + } + + /** + * User SignUp/Registration flow + */ + const response = await signUp({ + formFields: [ + { + id: "email", + value: formInputState.email, + }, + { + id: "password", + value: formInputState.password, + }, + ], + }); + if (response.status === "FIELD_ERROR") { + for (const formField of response.formFields) { + throw new Error(formField.error); + } + } else if (response.status === "SIGN_UP_NOT_ALLOWED") { + throw new Error(response.reason); + } else { + toast.success("Registration Successful. Login to continue."); + setAuthenticationType("login"); + } + } catch (error) { + console.error(error); + const errorMessage = (error as STGeneralError | Error)?.message || "Oops! Something went wrong."; + toast.error(errorMessage); + } finally { + /** + * Reset the form input state and loading state + */ + setFormInputState(initialFormInputState); + setIsLoading(false); + } + }; + + useEffect(() => { + if (!sessionExists.isLoading && sessionExists.isLoggedIn) { + navigation("/dashboard"); + } + }, [sessionExists]); + + useEffect(() => { + setFormInputState(initialFormInputState); + }, [authenticationType]); + + if (sessionExists.isLoading) { + return ; + } + return ( +
+
+ {showHeader &&
} +
+ + + + + {authenticationType === "registration" && ( + + )} + + {authenticationType === "login" && ( + + Not yet SignedUp?{" "} + + + )} + {authenticationType === "registration" && ( + + Already Registered?{" "} + + + )} +
+
+ {showFooter &&
} +
+ ); +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/emailpassword/index.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/emailpassword/index.tsx new file mode 100644 index 00000000..a131db2c --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/emailpassword/index.tsx @@ -0,0 +1,11 @@ +import { RouteObject } from "react-router-dom"; +import LoginAndRegister from "./LoginAndRegister"; + +const AuthRoutes: RouteObject[] = [ + { + path: "/authenticate", + element: , + }, +]; + +export default AuthRoutes; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/index.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/index.tsx new file mode 100644 index 00000000..62cd549a --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/index.tsx @@ -0,0 +1,8 @@ +/** + * This is a dummy file + */ +import { RouteObject } from "react-router-dom"; + +const AuthRoutes: RouteObject[] = []; + +export default AuthRoutes; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/passwordless/PasswordlessSignIn.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/passwordless/PasswordlessSignIn.tsx new file mode 100644 index 00000000..31ef7450 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/passwordless/PasswordlessSignIn.tsx @@ -0,0 +1,208 @@ +import Footer from "@/components/Footer"; +import Header from "@/components/Header"; +import Input from "@/components/Input"; +import Spinner from "@/components/Spinner"; +import useSessionInfo from "@/hooks/useSessionInfo"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "react-toastify"; +import { createCode, resendCode, clearLoginAttemptInfo, consumeCode } from "supertokens-web-js/recipe/passwordless"; +import STGeneralError from "supertokens-web-js/utils/error"; + +enum SCREEN { + EMAIL, + OTP, +} + +interface PasswordlessSignInProps { + showHeader?: boolean; + showFooter?: boolean; + rootStyle?: React.CSSProperties; +} + +export default function PasswordlessSignIn({ + showFooter = true, + showHeader = true, + rootStyle, +}: PasswordlessSignInProps) { + const [email, setEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [screen, setScreen] = useState(SCREEN.EMAIL); + const [isLoading, setIsLoading] = useState(false); + const navigation = useNavigate(); + const { sessionExists } = useSessionInfo(); + + const handleResendOTP = async () => { + setIsLoading(true); + try { + const response = await resendCode(); + + if (response.status === "RESTART_FLOW_ERROR") { + // this can happen if the user has already successfully logged in into + // another device whilst also trying to login to this one. + + // we clear the login attempt info that was added when the createCode function + // was called - so that if the user does a page reload, they will now see the + // enter email / phone UI again. + await clearLoginAttemptInfo(); + toast.error("Login failed. Please try again"); + return; + } + toast.success("OTP Resent Successfully"); + return; + } catch (error) { + console.error(error); + const errorMessage = (error as STGeneralError | Error)?.message || "Oops! Something went wrong."; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setIsLoading(true); + try { + /** + * Email Screen + */ + if (screen === SCREEN.EMAIL) { + const response = await createCode({ + email, + }); + /** + * For phone number, use this: + + let response = await createPasswordlessCode({ + phoneNumber: "+1234567890" + }); + + */ + + if (response.status === "SIGN_IN_UP_NOT_ALLOWED") { + // the reason string is a user friendly message + // about what went wrong. It can also contain a support code which users + // can tell you so you know why their sign in / up was not allowed. + throw new Error(response.reason); + } + setScreen(SCREEN.OTP); + toast.success("OTP Sent Successfully"); + return; + } + + /** + * OTP Screen + */ + const response = await consumeCode({ + userInputCode: otp, + }); + + if (response.status === "OK") { + // we clear the login attempt info that was added when the createCode function + // was called since the login was successful. + await clearLoginAttemptInfo(); + if (response.createdNewRecipeUser && response.user.loginMethods.length === 1) { + toast.success("New User Created. Please Link your email and password"); + } else { + toast.success("Logged In Successfully"); + } + navigation("/dashboard"); + return; + } else if (response.status === "INCORRECT_USER_INPUT_CODE_ERROR") { + throw new Error( + "Wrong OTP! Please try again. Number of attempts left: " + + (response.maximumCodeInputAttempts - response.failedCodeInputAttemptCount) + ); + } else if (response.status === "EXPIRED_USER_INPUT_CODE_ERROR") { + throw new Error("Old OTP entered. Please regenerate a new one and try again"); + } else { + // this can happen if the user tried an incorrect OTP too many times. + // or if it was denied due to security reasons in case of automatic account linking + + // we clear the login attempt info that was added when the createCode function + // was called - so that if the user does a page reload, they will now see the + // enter email / phone UI again. + await clearLoginAttemptInfo(); + setScreen(SCREEN.EMAIL); + throw new Error("Login failed. Please try again"); + } + } catch (error: unknown) { + console.error(error); + const errorMessage = (error as STGeneralError | Error)?.message || "Oops! Something went wrong."; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + useEffect(() => { + if (!sessionExists.isLoading && sessionExists.isLoggedIn) { + navigation("/dashboard"); + } + }, [sessionExists]); + + if (sessionExists.isLoading) { + return ; + } + + return ( +
+
+ {showHeader &&
} +
+ {screen === SCREEN.EMAIL && ( + + )} + {screen === SCREEN.OTP && ( + + )} + + {screen === SCREEN.OTP && ( + + Haven't Received the OTP yet?{" "} + + + )} +
+
+ {showFooter &&
} +
+ ); +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/passwordless/index.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/passwordless/index.tsx new file mode 100644 index 00000000..47ac9a4a --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/passwordless/index.tsx @@ -0,0 +1,11 @@ +import { RouteObject } from "react-router-dom"; +import PasswordlessSignIn from "./PasswordlessSignIn"; + +const AuthRoutes: RouteObject[] = [ + { + path: "/authenticate", + element: , + }, +]; + +export default AuthRoutes; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/CallbackHandler.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/CallbackHandler.tsx new file mode 100644 index 00000000..a962290c --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/CallbackHandler.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import STGeneralError from "supertokens-web-js/utils/error"; +import { signInAndUp } from "supertokens-web-js/recipe/thirdparty"; +import { toast } from "react-toastify"; +import Spinner from "@/components/Spinner"; + +function CallbackHandler() { + const alreadyExecuted = useRef(false); + const navigate = useNavigate(); + useEffect(() => { + async function handleCallback() { + try { + const response = await signInAndUp(); + + if (response.status === "OK") { + if (response.createdNewRecipeUser && response.user.loginMethods.length === 1) { + toast.success("Account created successfully"); + } else { + toast.success("Logged in successfully"); + } + navigate("/dashboard"); + return; + } else if (response.status === "SIGN_IN_UP_NOT_ALLOWED") { + // the reason string is a user friendly message + // about what went wrong. It can also contain a support code which users + // can tell you so you know why their sign in / up was not allowed. + throw new Error(response.reason); + } else { + // SuperTokens requires that the third party provider + // gives an email for the user. If that's not the case, sign up / in + // will fail. + + // As a hack to solve this, you can override the backend functions to create a fake email for the user. + + throw new Error("No email provided by social login. Please use another form of login"); + } + } catch (error: unknown) { + console.error(error); + const errorMessage = (error as STGeneralError | Error)?.message || "Oops! Something went wrong."; + toast.error(errorMessage); + navigate("/authenticate"); + } + } + if (!alreadyExecuted.current) { + handleCallback(); + alreadyExecuted.current = true; + } + }, []); + return ; +} + +export default CallbackHandler; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/SocialLogin.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/SocialLogin.tsx new file mode 100644 index 00000000..5e57d4e0 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/SocialLogin.tsx @@ -0,0 +1,85 @@ +import Footer from "@/components/Footer"; +import Header from "@/components/Header"; +import useSessionInfo from "@/hooks/useSessionInfo"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { SUPPORTED_PROVIDERS } from "./constants"; +import { getWebsiteDomain } from "@/config"; +import { getAuthorisationURLWithQueryParamsAndSetState } from "supertokens-web-js/recipe/thirdparty"; +import STGeneralError from "supertokens-web-js/utils/error"; +import { toast } from "react-toastify"; +import Spinner from "@/components/Spinner"; + +interface SocialLoginProps { + showHeader?: boolean; + showFooter?: boolean; + rootStyle?: React.CSSProperties; + view?: "full" | "compact"; +} + +export default function SocialLogin({ + showFooter = true, + showHeader = true, + rootStyle, + view = "full", +}: SocialLoginProps) { + const { sessionExists } = useSessionInfo(); + const [isLoading, setIsLoading] = useState(false); + const navigation = useNavigate(); + + const handleLoginRequest = async (provider: string) => { + setIsLoading(true); + try { + const authUrl = await getAuthorisationURLWithQueryParamsAndSetState({ + thirdPartyId: provider, + frontendRedirectURI: `${getWebsiteDomain()}/authenticate/callback/${provider}`, + }); + window.location.assign(authUrl); + } catch (error: unknown) { + console.error(error); + const errorMessage = (error as STGeneralError | Error)?.message || "Oops! Something went wrong."; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (!sessionExists.isLoading && sessionExists.isLoggedIn) { + navigation("/dashboard"); + } + }, [sessionExists]); + + if (sessionExists.isLoading) { + return ; + } + + return ( +
+
+ {showHeader &&
} +
+ {SUPPORTED_PROVIDERS.map((provider, index) => ( + + ))} +
+
+ {showFooter &&
} +
+ ); +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/constants.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/constants.tsx new file mode 100644 index 00000000..687d0665 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/constants.tsx @@ -0,0 +1,33 @@ +import { FaGoogle, FaGithub, FaApple } from "react-icons/fa"; +import { RiTwitterXLine } from "react-icons/ri"; + +interface ISupportedProviders { + key: string; + name: string; + icon: React.ReactNode; +} + +const SUPPORTED_PROVIDERS: ISupportedProviders[] = [ + { + key: "google", + name: "Google", + icon: , + }, + { + key: "github", + name: "Github", + icon: , + }, + { + key: "twitter", + name: "X(Twitter)", + icon: , + }, + { + key: "apple", + name: "Apple", + icon: , + }, +]; + +export { SUPPORTED_PROVIDERS }; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/index.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/index.tsx new file mode 100644 index 00000000..3b489cdb --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdparty/index.tsx @@ -0,0 +1,16 @@ +import { RouteObject } from "react-router-dom"; +import SocialLogin from "./SocialLogin"; +import CallbackHandler from "./CallbackHandler"; + +const AuthRoutes: RouteObject[] = [ + { + path: "/authenticate", + element: , + }, + { + path: "/authenticate/callback/:provider", + element: , + }, +]; + +export default AuthRoutes; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/SocialAndEmailPassword.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/SocialAndEmailPassword.tsx new file mode 100644 index 00000000..b6e511e3 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/SocialAndEmailPassword.tsx @@ -0,0 +1,19 @@ +import Social from "./thirdparty/SocialLogin"; +import LoginAndRegister from "./emailpassword/LoginAndRegister"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; + +export default function SocialAndPasswordless() { + return ( +
+
+ +
+

or

+

SignIn with

+
+ +
+
+ ); +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/emailpassword/LoginAndRegister.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/emailpassword/LoginAndRegister.tsx new file mode 100644 index 00000000..9c06f52a --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/emailpassword/LoginAndRegister.tsx @@ -0,0 +1,12 @@ +/** + * This is a dummy component and would be replaced during runtime + */ +interface LoginAndRegisterProps { + showHeader?: boolean; + showFooter?: boolean; + rootStyle?: React.CSSProperties; +} + +const LoginAndRegister: React.FC = () => <>; + +export default LoginAndRegister; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/index.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/index.tsx new file mode 100644 index 00000000..14c3ffe9 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/index.tsx @@ -0,0 +1,15 @@ +import { RouteObject } from "react-router-dom"; +import SocialAndEmailPassword from "./SocialAndEmailPassword"; +import CallbackHandler from "./thirdparty/CallbackHandler"; +const AuthRoutes: RouteObject[] = [ + { + path: "/authenticate", + element: , + }, + { + path: "/authenticate/callback/:provider", + element: , + }, +]; + +export default AuthRoutes; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/thirdparty/CallbackHandler.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/thirdparty/CallbackHandler.tsx new file mode 100644 index 00000000..e518dea5 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/thirdparty/CallbackHandler.tsx @@ -0,0 +1,9 @@ +/** + * This is a dummy component which would be replaced during runtime + */ + +function CallbackHandler() { + return <>; +} + +export default CallbackHandler; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/thirdparty/SocialLogin.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/thirdparty/SocialLogin.tsx new file mode 100644 index 00000000..5034ea5f --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartyemailpassword/thirdparty/SocialLogin.tsx @@ -0,0 +1,14 @@ +/** + * This is a dummy component which would be replaced during runtime + */ + +interface SocialLoginProps { + showHeader?: boolean; + showFooter?: boolean; + rootStyle?: React.CSSProperties; + view?: "full" | "compact"; +} + +const SocialLogin: React.FC = () => <>; + +export default SocialLogin; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/SocialAndPasswordless.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/SocialAndPasswordless.tsx new file mode 100644 index 00000000..feb51263 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/SocialAndPasswordless.tsx @@ -0,0 +1,19 @@ +import Social from "./thirdparty/SocialLogin"; +import PasswordLess from "./passwordless/PasswordlessSignIn"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; + +export default function SocialAndPasswordless() { + return ( +
+
+ +
+

or

+

SignIn with

+
+ +
+
+ ); +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/index.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/index.tsx new file mode 100644 index 00000000..03374c59 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/index.tsx @@ -0,0 +1,15 @@ +import { RouteObject } from "react-router-dom"; +import SocialAndPasswordless from "./SocialAndPasswordless"; +import CallbackHandler from "./thirdparty/CallbackHandler"; +const AuthRoutes: RouteObject[] = [ + { + path: "/authenticate", + element: , + }, + { + path: "/authenticate/callback/:provider", + element: , + }, +]; + +export default AuthRoutes; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/passwordless/PasswordlessSignIn.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/passwordless/PasswordlessSignIn.tsx new file mode 100644 index 00000000..58a5ba14 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/passwordless/PasswordlessSignIn.tsx @@ -0,0 +1,13 @@ +/** + * This is a dummy component which would be replaced during runtime + */ + +interface PasswordlessSignInProps { + showHeader?: boolean; + showFooter?: boolean; + rootStyle?: React.CSSProperties; +} + +const PasswordlessSignIn: React.FC = () => <>; + +export default PasswordlessSignIn; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/thirdparty/CallbackHandler.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/thirdparty/CallbackHandler.tsx new file mode 100644 index 00000000..e518dea5 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/thirdparty/CallbackHandler.tsx @@ -0,0 +1,9 @@ +/** + * This is a dummy component which would be replaced during runtime + */ + +function CallbackHandler() { + return <>; +} + +export default CallbackHandler; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/thirdparty/SocialLogin.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/thirdparty/SocialLogin.tsx new file mode 100644 index 00000000..5034ea5f --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Auth/thirdpartypasswordless/thirdparty/SocialLogin.tsx @@ -0,0 +1,14 @@ +/** + * This is a dummy component which would be replaced during runtime + */ + +interface SocialLoginProps { + showHeader?: boolean; + showFooter?: boolean; + rootStyle?: React.CSSProperties; + view?: "full" | "compact"; +} + +const SocialLogin: React.FC = () => <>; + +export default SocialLogin; diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Dashboard/index.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Dashboard/index.tsx new file mode 100644 index 00000000..4ba48440 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Dashboard/index.tsx @@ -0,0 +1,27 @@ +import { useNavigate } from "react-router-dom"; +import { signOut } from "supertokens-web-js/recipe/session"; + +export default function DashboardPage() { + return ( +
+

This is a protected page you are viewing

+ +
+ ); +} + +function SignOutButton() { + const navigate = useNavigate(); + const handleClick = async () => { + await signOut(); + navigate("/authenticate"); + }; + return ( + + ); +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/pages/Home/index.tsx b/boilerplate/frontend/supertokens-react-custom/src/pages/Home/index.tsx new file mode 100644 index 00000000..da942848 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/pages/Home/index.tsx @@ -0,0 +1,10 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +export default function HomePage() { + const navigate = useNavigate(); + useEffect(() => { + navigate("/dashboard"); + }, []); + return <>; +} diff --git a/boilerplate/frontend/supertokens-react-custom/src/vite-env.d.ts b/boilerplate/frontend/supertokens-react-custom/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/boilerplate/frontend/supertokens-react-custom/tailwind.config.js b/boilerplate/frontend/supertokens-react-custom/tailwind.config.js new file mode 100644 index 00000000..b10fc3f0 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/tailwind.config.js @@ -0,0 +1,25 @@ +/** @type {import('tailwindcss').Config} */ + +export default { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + colors: { + "golden-bell": { + 50: "#fdf9ef", + 100: "#fbf0d9", + 200: "#f5ddb3", + 300: "#efc682", + 400: "#e7a550", + 500: "#e28d31", + 600: "#d37223", + 700: "#af591f", + 800: "#8c4720", + 900: "#713b1d", + 950: "#3d1d0d", + }, + }, + }, + }, + plugins: [], +}; diff --git a/boilerplate/frontend/supertokens-react-custom/tsconfig.app.json b/boilerplate/frontend/supertokens-react-custom/tsconfig.app.json new file mode 100644 index 00000000..34f4a1b7 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/boilerplate/frontend/supertokens-react-custom/tsconfig.json b/boilerplate/frontend/supertokens-react-custom/tsconfig.json new file mode 100644 index 00000000..b69e28ef --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/tsconfig.json @@ -0,0 +1,12 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "forceConsistentCasingInFileNames": true, + "strict": true + } +} diff --git a/boilerplate/frontend/supertokens-react-custom/tsconfig.node.json b/boilerplate/frontend/supertokens-react-custom/tsconfig.node.json new file mode 100644 index 00000000..716bc288 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/boilerplate/frontend/supertokens-react-custom/vite.config.ts b/boilerplate/frontend/supertokens-react-custom/vite.config.ts new file mode 100644 index 00000000..07227d53 --- /dev/null +++ b/boilerplate/frontend/supertokens-react-custom/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/lib/build/config.js b/lib/build/config.js index 451ce50e..9b7a9609 100644 --- a/lib/build/config.js +++ b/lib/build/config.js @@ -1,7 +1,11 @@ +import { UIBuildType } from "./types.js"; +import { FILTER_CHOICES_STRATEGY, filterChoices } from "./filterChoicesUtils.js"; import { validateFolderName } from "./userArgumentUtils.js"; import { getDjangoPythonRunScripts, + getFrontendPromptMessage, getPythonRunScripts, + getRecipePromptMessage, mapOptionsToChoices, shouldSkipBackendQuestion, } from "./questionUtils.js"; @@ -20,7 +24,7 @@ export async function getFrontendOptions({ manager }) { value: "react", displayName: "React", location: { - main: "frontend/supertokens-react", + main: `frontend/supertokens-react`, config: [{ finalConfig: "/src/config.tsx", configFiles: "/config" }], }, script: { @@ -28,6 +32,18 @@ export async function getFrontendOptions({ manager }) { run: [`${manager} run start`], }, }, + { + value: "react-custom", + displayName: "React", + location: { + main: `frontend/supertokens-react-custom`, + config: [{ finalConfig: "/src/config.ts", configFiles: "/src/config" }], + }, + script: { + setup: [`${manager} install`], + run: [`${manager} run start`], + }, + }, { value: "react-multitenancy", displayName: "React", @@ -458,6 +474,16 @@ export const recipeOptions = [ displayName: "Multi-factor Authentication", }, ]; +export const uiBuildOptions = [ + { + value: UIBuildType.PRE_BUILT, + displayName: "Pre-built UI (Recommended)", + }, + { + value: UIBuildType.CUSTOM, + displayName: "Custom UI", + }, +]; /** * Export for all the questions to ask the user, should follow the exact format mentioned here https://github.com/SBoudrias/Inquirer.js#objects because this config is passed to inquirer. The order of questions depends on the position of the object in the array */ @@ -468,7 +494,7 @@ export async function getQuestions(flags) { type: "input", message: "What is your app called?", default: "my-app", - when: flags.appname === undefined, + when: (answers) => !answers.appname, validate: function (input) { const validations = validateFolderName(input); if (validations.valid) { @@ -482,24 +508,31 @@ export async function getQuestions(flags) { return "Invalid project name: " + validations.problems[0]; }, }, + { + name: "ui", + type: "list", + message: "Choose the ui build type for your frontend:", + choices: mapOptionsToChoices(uiBuildOptions), + when: (answers) => !answers.ui, + }, { name: "frontend", type: "list", - message: "Choose a frontend framework (Visit our documentation for integration with other frameworks):", - choices: mapOptionsToChoices(await getFrontendOptions(flags)), - when: flags.frontend === undefined, + message: (answers) => getFrontendPromptMessage(answers), + choices: async (answers) => + filterChoices( + mapOptionsToChoices(await getFrontendOptions(flags)), + answers, + FILTER_CHOICES_STRATEGY.filterFrontendByUiType + ), + when: (answers) => !answers.frontend && !!answers.ui, }, { name: "frontendNext", type: "list", message: "Choose how you want to organise your Next.js routes:", choices: mapOptionsToChoices(await getNextJSOptions(flags)), - when: (answers) => { - if (flags.frontend !== undefined && flags.frontend === "next") { - return true; - } - return answers.frontend === "next"; - }, + when: (answers) => answers.frontend === "next", }, { name: "backend", @@ -517,17 +550,17 @@ export async function getQuestions(flags) { message: "Choose a Python framework:", choices: mapOptionsToChoices(pythonOptions), when: (answers) => { - if (flags.backend !== undefined && flags.backend !== "python") { + if (answers.backend !== undefined && answers.backend !== "python") { return false; } if ( - (flags.frontend !== undefined && flags.frontend.startsWith("next")) || + (answers.frontend !== undefined && answers.frontend.startsWith("next")) || (answers.frontend !== undefined && answers.frontend.startsWith("next")) ) { // This means that they want to use nextjs fullstack return false; } - if (answers.backend !== "python" && flags.backend !== "python") { + if (answers.backend !== "python" && answers.backend !== "python") { return false; } return true; @@ -536,14 +569,19 @@ export async function getQuestions(flags) { { name: "recipe", type: "list", - message: "What type of authentication do you want to use?", - choices: mapOptionsToChoices(recipeOptions), + message: (answers) => getRecipePromptMessage(answers), + choices: async (answers) => + filterChoices( + mapOptionsToChoices(recipeOptions), + answers, + FILTER_CHOICES_STRATEGY.filterRecipeByUiType + ), when: (answers) => { // For capacitor we don't ask this question because it has its own way of swapping between recipes if (answers.frontend === "capacitor") { return false; } - return flags.recipe === undefined; + return !answers.recipe; }, }, ]; diff --git a/lib/build/filterChoicesUtils.js b/lib/build/filterChoicesUtils.js new file mode 100644 index 00000000..8e59d954 --- /dev/null +++ b/lib/build/filterChoicesUtils.js @@ -0,0 +1,172 @@ +import { allFrontends, UIBuildType } from "./types.js"; +const getIsValueSupported = (value, supportedValues) => { + return supportedValues.includes(value); +}; +/** + * Frontend filter strategy based on the selected UI build type. + */ +/** + * Strategy for filtering frontend choices based on the UI build type. + * + * @implements {IPromptFilterStrategy} + * + * @property {function} filterChoices - Filters the available choices based on the selected UI build type. + * @param {Array} choices - The list of available choices. + * @param {Object} answers - The answers provided by the user. + * @returns {Array} - The filtered list of choices. + * + * @property {function} validateUserArguments - Validates the user-provided arguments based on the UI build type. + * @param {Object} userArguments - The arguments provided by the user. + * @returns {boolean} - Returns true if the user arguments are valid, otherwise false. + */ +const filterFrontendByUiType = { + filterChoices(choices, answers) { + /** + * For pre-built UI + */ + if (answers.ui === UIBuildType.PRE_BUILT) { + const prebuiltUiSupportedFrontends = getSupportedFrontendForUI(UIBuildType.PRE_BUILT); + return choices.filter((choice) => getIsValueSupported(choice.value, prebuiltUiSupportedFrontends)); + } + /** + * For custom UI + */ + const customUiSupportedFrontends = getSupportedFrontendForUI(UIBuildType.CUSTOM); + return choices.filter((choice) => getIsValueSupported(choice.value, customUiSupportedFrontends)); + }, + validateUserArguments(userArguments) { + /** + * For pre-built UI + */ + const prebuiltUiSupportedFrontends = getSupportedFrontendForUI(UIBuildType.PRE_BUILT); + if ( + !userArguments.frontend || + (userArguments.ui === UIBuildType.PRE_BUILT && + getIsValueSupported(userArguments.frontend, prebuiltUiSupportedFrontends)) + ) { + return true; + } + /** + * For custom UI + */ + const customUiSupportedFrontends = getSupportedFrontendForUI(UIBuildType.CUSTOM); + return getIsValueSupported(userArguments.frontend, customUiSupportedFrontends); + }, +}; +/** + * Recipe filter strategy based on the selected UI build type. + */ +const filterRecipeByUiType = { + filterChoices(choices, answers) { + /** + * For pre-built UI + */ + const prebuiltUiSupportedRecipes = getSupportedRecipeForUI(UIBuildType.PRE_BUILT); + if (answers.ui === UIBuildType.PRE_BUILT) { + return choices.filter((choice) => getIsValueSupported(choice.value, prebuiltUiSupportedRecipes)); + } + /** + * For custom UI + */ + const customUiSupportedRecipes = getSupportedRecipeForUI(UIBuildType.CUSTOM); + return choices.filter((choice) => getIsValueSupported(choice.value, customUiSupportedRecipes)); + }, + validateUserArguments(userArguments) { + /** + * For pre-built UI + */ + const prebuiltUiSupportedRecipes = getSupportedRecipeForUI(UIBuildType.PRE_BUILT); + if ( + !userArguments.recipe || + (userArguments.ui === UIBuildType.PRE_BUILT && + getIsValueSupported(userArguments.recipe, prebuiltUiSupportedRecipes)) + ) + return true; + /** + * For custom UI + */ + const customUiSupportedRecipes = getSupportedRecipeForUI(UIBuildType.CUSTOM); + return getIsValueSupported(userArguments.recipe, customUiSupportedRecipes); + }, +}; +export const FILTER_CHOICES_STRATEGY = { + filterFrontendByUiType, + filterRecipeByUiType, +}; +/** + * Filters a list of choices based on the provided strategy or strategies. + * + * @param choices - An array of `PromptListChoice` objects or a promise that resolves to such an array. + * @param answers - An object containing the answers to previous prompts. + * @param strategy - An optional filtering strategy or an array of strategies. This can be one of the predefined + * `FILTER_CHOICES_STRATEGY` values, an array of such values, or a custom function that takes an + * array of choices and returns a promise that resolves to a filtered array of choices. + * @returns A promise that resolves to the filtered array of `PromptListChoice` objects. + */ +export const filterChoices = async (choices, answers, strategy) => { + if (!strategy || (Array.isArray(strategy) && strategy.length === 0)) { + return choices; + } + if (!Array.isArray(strategy)) { + strategy = [strategy]; + } + for (const filter of strategy) { + choices = filter.filterChoices(choices, answers); + } + return choices; +}; +export const validateUserArgumentsByFilterStrategy = (userArguments, argumentToValidateKey, strategy) => { + if (!strategy) { + return true; + } + if (!Array.isArray(strategy)) { + strategy = [strategy]; + } + if (!userArguments?.[argumentToValidateKey]) { + throw new Error(`Invalid ${argumentToValidateKey} provided`); + } + for (const filter of strategy) { + if (!filter.validateUserArguments(userArguments)) { + return false; + } + } + return true; +}; +export const isValidUiType = (userArguments) => { + if (!Object.values(UIBuildType).includes(userArguments.ui)) return false; + return validateUserArgumentsByFilterStrategy(userArguments, "ui", [ + FILTER_CHOICES_STRATEGY.filterFrontendByUiType, + FILTER_CHOICES_STRATEGY.filterRecipeByUiType, + ]); +}; +/** + * Retrieves the supported frontend frameworks for a given UI build type. + * + * @param {UIBuildType} ui - The type of UI build. + * @returns {SupportedFrontends[]} An array of supported frontend frameworks for the given UI. + */ +const getSupportedFrontendForUI = (ui) => { + const CUSTOM_ONLY = ["react-custom"]; + if (ui === UIBuildType.PRE_BUILT) { + // Return all frontends except the custom only ones + return allFrontends.map((frontend) => frontend.id).filter((frontend) => !CUSTOM_ONLY.includes(frontend)); + } + return CUSTOM_ONLY; +}; +/** + * Retrieves the list of supported recipes for the given UI build type. + * + * @param {UIBuildType} ui - The type of UI build (e.g., PRE_BUILT or CUSTOM). + * @returns {string[]} An array of supported recipe names for the given UI Build type. + */ +const getSupportedRecipeForUI = (ui) => { + const CUSTOM_SUPPORTED_RECIPES = [ + "emailpassword", + "thirdparty", + "passwordless", + "thirdpartypasswordless", + "thirdpartyemailpassword", + ]; + const PREBUILT_SUPPORTED_RECIPES = [...CUSTOM_SUPPORTED_RECIPES, "all_auth", "multitenancy", "multifactorauth"]; + return ui === UIBuildType.PRE_BUILT ? PREBUILT_SUPPORTED_RECIPES : CUSTOM_SUPPORTED_RECIPES; +}; diff --git a/lib/build/index.js b/lib/build/index.js index 9251c200..7a81fe0b 100755 --- a/lib/build/index.js +++ b/lib/build/index.js @@ -5,8 +5,9 @@ import { getDownloadLocationFromAnswers, downloadApp, setupProject, runProjectOr import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { - modifyAnswersBasedOnFlags, + generateInitialAnswers, modifyAnswersBasedOnSelection, + modifyUserArgumentsForAliasFlags, validateUserArguments, } from "./userArgumentUtils.js"; import { Logger } from "./logger.js"; @@ -88,7 +89,8 @@ async function run() { --manager: Which package manager to use --autostart: Whether the CLI should start the project after setting up */ - const userArgumentsRaw = await yargs(hideBin(process.argv)).argv; + let userArgumentsRaw = await yargs(hideBin(process.argv)).argv; + userArgumentsRaw = modifyUserArgumentsForAliasFlags(userArgumentsRaw); validateUserArguments(userArgumentsRaw); const userArguments = { ...userArgumentsRaw, @@ -103,8 +105,8 @@ async function run() { eventName: "cli_started", }); // Inquirer prompts all the questions to the user, answers will be an object that contains all the responses - answers = await inquirer.prompt(await getQuestions(userArguments)); - answers = modifyAnswersBasedOnFlags(answers, userArguments); + const initialAnswers = generateInitialAnswers(userArguments); + answers = await inquirer.prompt(await getQuestions(userArguments), initialAnswers); answers = modifyAnswersForPythonFrameworks(answers); answers = modifyAnswersBasedOnSelection(answers); AnalyticsManager.sendAnalyticsEvent({ diff --git a/lib/build/questionUtils.js b/lib/build/questionUtils.js index 59ad0987..b725ef85 100644 --- a/lib/build/questionUtils.js +++ b/lib/build/questionUtils.js @@ -1,4 +1,5 @@ import os from "os"; +import { UIBuildType } from "./types.js"; export function getPythonRunScripts() { if (os.platform() === "win32") { return [ @@ -85,3 +86,21 @@ export function shouldSkipBackendQuestion(answers, userFlags) { answers.frontend === "sveltekit" ); } +export function getFrontendPromptMessage(answers) { + if (answers.ui === UIBuildType.CUSTOM) { + return ( + "Pick a frontend framework. For other frameworks, check the docs or" + + " select 'Pre-built UI' in the previous 'UI Build Type' step." + ); + } + return "Choose a frontend framework (Visit our documentation for integration with other frameworks):"; +} +export function getRecipePromptMessage(answers) { + if (answers.ui === UIBuildType.CUSTOM) { + return ( + "What type of authentication do you want to use?. For other methods, check the docs or select 'Pre-built UI'" + + " in the previous 'UI Build Type' step." + ); + } + return "What type of authentication do you want to use?"; +} diff --git a/lib/build/scriptsUtils.js b/lib/build/scriptsUtils.js new file mode 100644 index 00000000..238f8583 --- /dev/null +++ b/lib/build/scriptsUtils.js @@ -0,0 +1,68 @@ +import { UIBuildType } from "./types.js"; +import fs from "fs/promises"; +import path from "path"; +export async function executeSetupStepsIfExists(directoryPath, answers) { + if (answers.ui === UIBuildType.CUSTOM && answers.frontend === "react-custom") { + await modifyDirectoryCustomReact(directoryPath, answers); + } +} +async function modifyDirectoryCustomReact(frontendDir, answers) { + const recipe = answers.recipe; + const authPath = path.join(frontendDir, "src/pages/Auth"); + const allItems = await fs.readdir(authPath); + /** + * Verify if the recipe folder exists + */ + const recipeFolder = allItems.filter((item) => item !== recipe); + if (!recipeFolder || recipeFolder.length === 0) { + throw new Error("recipe folder not found"); + } + /** + * Get Recipe Dependencies which would be injected into the recipe folder + * Step1: check if the dependency Exists + * Step2: Remove existing folder with the dependency name from the recipe folder + * Step3: Delete the index.tsx file from the dependency folder + * Step4: Copy the dependency folder from the Auth folder to the recipe folder + */ + const dependencies = getRecipeDependencies(recipe); + for (const dependency of dependencies) { + if (!allItems.includes(dependency)) { + throw new Error(`Dependency ${dependency} not found`); + } + const dependencyPath = path.join(authPath, dependency); + const recipeDependencyPath = path.join(authPath, recipe, dependency); + await fs.rm(recipeDependencyPath, { recursive: true }); + await fs.rm(path.join(dependencyPath, "index.tsx")); + await fs.cp(dependencyPath, recipeDependencyPath, { recursive: true }); + } + /** + * Remove all the folders except the recipe folder + */ + for (const item of allItems) { + if (item === recipe) { + continue; + } + const itemPath = path.join(authPath, item); + await fs.rm(itemPath, { recursive: true }); + } + /** + * Move all the files from the recipe folder to Auth folder + */ + const allItemsInsideRecipe = await fs.readdir(path.join(authPath, recipe)); + for (const item of allItemsInsideRecipe) { + const itemPath = path.join(authPath, recipe, item); + const newPath = path.join(authPath, item); + await fs.cp(itemPath, newPath, { recursive: true }); + } + /** + * Remove the recipe folder as part of cleanup + */ + await fs.rm(path.join(authPath, recipe), { recursive: true }); +} +export function getRecipeDependencies(recipe) { + const recipeDependencies = { + thirdpartyemailpassword: ["thirdparty", "emailpassword"], + thirdpartypasswordless: ["thirdparty", "passwordless"], + }; + return recipeDependencies?.[recipe] || []; +} diff --git a/lib/build/types.js b/lib/build/types.js index fc0e15df..99cb85e7 100644 --- a/lib/build/types.js +++ b/lib/build/types.js @@ -18,6 +18,9 @@ export const allFrontends = [ { id: "react", }, + { + id: "react-custom", + }, { id: "next", }, @@ -79,6 +82,11 @@ export function isValidBackend(backend) { return false; } export const allPackageManagers = ["npm", "yarn", "pnpm", "bun"]; +export var UIBuildType; +(function (UIBuildType) { + UIBuildType["CUSTOM"] = "custom"; + UIBuildType["PRE_BUILT"] = "pre-built"; +})(UIBuildType || (UIBuildType = {})); export function isValidPackageManager(manager) { if (allPackageManagers.includes(manager)) { return true; diff --git a/lib/build/userArgumentUtils.js b/lib/build/userArgumentUtils.js index 178a1e8f..d988c649 100644 --- a/lib/build/userArgumentUtils.js +++ b/lib/build/userArgumentUtils.js @@ -7,9 +7,11 @@ import { isValidFrontend, isValidPackageManager, isValidRecipeName, + UIBuildType, } from "./types.js"; import validateProjectName from "validate-npm-package-name"; import path from "path"; +import { isValidUiType } from "./filterChoicesUtils.js"; export function validateNpmName(name) { const nameValidation = validateProjectName(name); if (nameValidation.validForNewPackages) { @@ -60,6 +62,15 @@ export function validateUserArguments(userArguments) { throw new Error("Invalid package manager provided, valid values:\n" + availableManagers); } } + if (userArguments.ui !== undefined) { + if (!isValidUiType(userArguments)) { + throw new Error( + `Invalid UI type provided, valid values: ${Object.values(UIBuildType).join( + ", " + )} or provided frontend or recipe is not compatible with the UI type` + ); + } + } } export function modifyAnswersBasedOnSelection(answers) { let _answers = answers; @@ -79,14 +90,17 @@ export function modifyAnswersBasedOnSelection(answers) { } return _answers; } -export function modifyAnswersBasedOnFlags(answers, userArguments) { - let _answers = answers; +export function generateInitialAnswers(userArguments) { + let _answers = {}; if (userArguments.appname !== undefined) { _answers.appname = userArguments.appname; } if (userArguments.recipe !== undefined) { _answers.recipe = userArguments.recipe; } + if (userArguments.ui !== undefined) { + _answers.ui = userArguments.ui; + } if (userArguments.frontend !== undefined) { const selectedFrontend = allFrontends.filter((i) => userArguments.frontend === i.id); if (selectedFrontend.length === 0) { @@ -112,3 +126,10 @@ export function getShouldAutoStartFromArgs(userArguments) { } return false; } +export function modifyUserArgumentsForAliasFlags(userArguments) { + let _userArguments = structuredClone(userArguments); + if (_userArguments.ui === UIBuildType.CUSTOM && _userArguments.frontend === "react") { + _userArguments.frontend = "react-custom"; + } + return _userArguments; +} diff --git a/lib/build/utils.js b/lib/build/utils.js index 399f756e..4290934d 100644 --- a/lib/build/utils.js +++ b/lib/build/utils.js @@ -12,6 +12,7 @@ import chalk from "chalk"; import { fileURLToPath } from "url"; import fetch from "node-fetch"; import { addPackageCommand } from "./packageManager.js"; +import { executeSetupStepsIfExists } from "./scriptsUtils.js"; const pipeline = promisify(stream.pipeline); const defaultSetupErrorString = "Project Setup Failed!"; function normaliseLocationPath(path) { @@ -294,6 +295,8 @@ async function setupFrontendBackendApp(answers, folderName, locations, userArgum force: true, }); } + spinner.text = "Executing setup scripts"; + await executeSetupStepsIfExists(`./${folderName}/frontend`, answers); spinner.text = "Installing frontend dependencies"; const frontendSetup = new Promise((res) => { let stderr = []; diff --git a/lib/ts/config.ts b/lib/ts/config.ts index 5a862297..2bcb1eb0 100644 --- a/lib/ts/config.ts +++ b/lib/ts/config.ts @@ -1,8 +1,11 @@ -import { Answers, QuestionOption, RecipeQuestionOption, UserFlags } from "./types.js"; +import { Answers, QuestionOption, RecipeQuestionOption, UIBuildType, UIBuildTypeOption, UserFlags } from "./types.js"; +import { FILTER_CHOICES_STRATEGY, filterChoices } from "./filterChoicesUtils.js"; import { validateFolderName } from "./userArgumentUtils.js"; import { getDjangoPythonRunScripts, + getFrontendPromptMessage, getPythonRunScripts, + getRecipePromptMessage, mapOptionsToChoices, shouldSkipBackendQuestion, } from "./questionUtils.js"; @@ -23,7 +26,7 @@ export async function getFrontendOptions({ manager }: UserFlags): Promise !answers.appname, validate: function (input: any) { const validations = validateFolderName(input); @@ -496,25 +522,31 @@ export async function getQuestions(flags: UserFlags) { return "Invalid project name: " + validations.problems![0]; }, }, + { + name: "ui", + type: "list", + message: "Choose the ui build type for your frontend:", + choices: mapOptionsToChoices(uiBuildOptions), + when: (answers: Answers) => !answers.ui, + }, { name: "frontend", type: "list", - message: "Choose a frontend framework (Visit our documentation for integration with other frameworks):", - choices: mapOptionsToChoices(await getFrontendOptions(flags)), - when: flags.frontend === undefined, + message: (answers: Answers) => getFrontendPromptMessage(answers), + choices: async (answers: Answers) => + filterChoices( + mapOptionsToChoices(await getFrontendOptions(flags)), + answers, + FILTER_CHOICES_STRATEGY.filterFrontendByUiType + ), + when: (answers: Answers) => !answers.frontend && !!answers.ui, }, { name: "frontendNext", type: "list", message: "Choose how you want to organise your Next.js routes:", choices: mapOptionsToChoices(await getNextJSOptions(flags)), - when: (answers: Answers) => { - if (flags.frontend !== undefined && flags.frontend === "next") { - return true; - } - - return answers.frontend === "next"; - }, + when: (answers: Answers) => answers.frontend === "next", }, { name: "backend", @@ -532,19 +564,19 @@ export async function getQuestions(flags: UserFlags) { message: "Choose a Python framework:", choices: mapOptionsToChoices(pythonOptions), when: (answers: Answers) => { - if (flags.backend !== undefined && flags.backend !== "python") { + if (answers.backend !== undefined && answers.backend !== "python") { return false; } if ( - (flags.frontend !== undefined && flags.frontend.startsWith("next")) || + (answers.frontend !== undefined && answers.frontend.startsWith("next")) || (answers.frontend !== undefined && answers.frontend.startsWith("next")) ) { // This means that they want to use nextjs fullstack return false; } - if (answers.backend !== "python" && flags.backend !== "python") { + if (answers.backend !== "python" && answers.backend !== "python") { return false; } @@ -554,15 +586,20 @@ export async function getQuestions(flags: UserFlags) { { name: "recipe", type: "list", - message: "What type of authentication do you want to use?", - choices: mapOptionsToChoices(recipeOptions), + message: (answers: Answers) => getRecipePromptMessage(answers), + choices: async (answers: Answers) => + filterChoices( + mapOptionsToChoices(recipeOptions), + answers, + FILTER_CHOICES_STRATEGY.filterRecipeByUiType + ), when: (answers: Answers) => { // For capacitor we don't ask this question because it has its own way of swapping between recipes if (answers.frontend === "capacitor") { return false; } - return flags.recipe === undefined; + return !answers.recipe; }, }, ]; diff --git a/lib/ts/filterChoicesUtils.ts b/lib/ts/filterChoicesUtils.ts new file mode 100644 index 00000000..dccdccb8 --- /dev/null +++ b/lib/ts/filterChoicesUtils.ts @@ -0,0 +1,201 @@ +import { + allFrontends, + Answers, + IPromptFilterStrategy, + PromptListChoice, + SupportedFrontends, + UIBuildType, + UserFlagsRaw, +} from "./types.js"; + +const getIsValueSupported = (value: string, supportedValues: string[]) => { + return supportedValues.includes(value); +}; + +/** + * Frontend filter strategy based on the selected UI build type. + */ +/** + * Strategy for filtering frontend choices based on the UI build type. + * + * @implements {IPromptFilterStrategy} + * + * @property {function} filterChoices - Filters the available choices based on the selected UI build type. + * @param {Array} choices - The list of available choices. + * @param {Object} answers - The answers provided by the user. + * @returns {Array} - The filtered list of choices. + * + * @property {function} validateUserArguments - Validates the user-provided arguments based on the UI build type. + * @param {Object} userArguments - The arguments provided by the user. + * @returns {boolean} - Returns true if the user arguments are valid, otherwise false. + */ +const filterFrontendByUiType: IPromptFilterStrategy = { + filterChoices(choices, answers) { + /** + * For pre-built UI + */ + if (answers.ui === UIBuildType.PRE_BUILT) { + const prebuiltUiSupportedFrontends = getSupportedFrontendForUI(UIBuildType.PRE_BUILT); + return choices.filter((choice) => getIsValueSupported(choice.value, prebuiltUiSupportedFrontends)); + } + + /** + * For custom UI + */ + const customUiSupportedFrontends = getSupportedFrontendForUI(UIBuildType.CUSTOM); + return choices.filter((choice) => getIsValueSupported(choice.value, customUiSupportedFrontends)); + }, + validateUserArguments(userArguments) { + /** + * For pre-built UI + */ + const prebuiltUiSupportedFrontends = getSupportedFrontendForUI(UIBuildType.PRE_BUILT); + if ( + !userArguments.frontend || + (userArguments.ui === UIBuildType.PRE_BUILT && + getIsValueSupported(userArguments.frontend, prebuiltUiSupportedFrontends)) + ) { + return true; + } + + /** + * For custom UI + */ + const customUiSupportedFrontends = getSupportedFrontendForUI(UIBuildType.CUSTOM); + return getIsValueSupported(userArguments.frontend, customUiSupportedFrontends); + }, +}; + +/** + * Recipe filter strategy based on the selected UI build type. + */ +const filterRecipeByUiType: IPromptFilterStrategy = { + filterChoices(choices, answers) { + /** + * For pre-built UI + */ + const prebuiltUiSupportedRecipes = getSupportedRecipeForUI(UIBuildType.PRE_BUILT); + if (answers.ui === UIBuildType.PRE_BUILT) { + return choices.filter((choice) => getIsValueSupported(choice.value, prebuiltUiSupportedRecipes)); + } + /** + * For custom UI + */ + const customUiSupportedRecipes = getSupportedRecipeForUI(UIBuildType.CUSTOM); + return choices.filter((choice) => getIsValueSupported(choice.value, customUiSupportedRecipes)); + }, + validateUserArguments(userArguments) { + /** + * For pre-built UI + */ + const prebuiltUiSupportedRecipes = getSupportedRecipeForUI(UIBuildType.PRE_BUILT); + if ( + !userArguments.recipe || + (userArguments.ui === UIBuildType.PRE_BUILT && + getIsValueSupported(userArguments.recipe, prebuiltUiSupportedRecipes)) + ) + return true; + /** + * For custom UI + */ + const customUiSupportedRecipes = getSupportedRecipeForUI(UIBuildType.CUSTOM); + return getIsValueSupported(userArguments.recipe, customUiSupportedRecipes); + }, +}; + +export const FILTER_CHOICES_STRATEGY = { + filterFrontendByUiType, + filterRecipeByUiType, +}; + +/** + * Filters a list of choices based on the provided strategy or strategies. + * + * @param choices - An array of `PromptListChoice` objects or a promise that resolves to such an array. + * @param answers - An object containing the answers to previous prompts. + * @param strategy - An optional filtering strategy or an array of strategies. This can be one of the predefined + * `FILTER_CHOICES_STRATEGY` values, an array of such values, or a custom function that takes an + * array of choices and returns a promise that resolves to a filtered array of choices. + * @returns A promise that resolves to the filtered array of `PromptListChoice` objects. + */ +export const filterChoices = async ( + choices: PromptListChoice[], + answers: Answers, + strategy?: IPromptFilterStrategy | IPromptFilterStrategy[] +): Promise => { + if (!strategy || (Array.isArray(strategy) && strategy.length === 0)) { + return choices; + } + if (!Array.isArray(strategy)) { + strategy = [strategy]; + } + for (const filter of strategy) { + choices = filter.filterChoices(choices, answers); + } + return choices; +}; + +export const validateUserArgumentsByFilterStrategy = ( + userArguments: UserFlagsRaw, + argumentToValidateKey: keyof UserFlagsRaw, + strategy?: IPromptFilterStrategy | IPromptFilterStrategy[] +): boolean => { + if (!strategy) { + return true; + } + if (!Array.isArray(strategy)) { + strategy = [strategy]; + } + if (!userArguments?.[argumentToValidateKey]) { + throw new Error(`Invalid ${argumentToValidateKey} provided`); + } + + for (const filter of strategy) { + if (!filter.validateUserArguments(userArguments)) { + return false; + } + } + return true; +}; + +export const isValidUiType = (userArguments: UserFlagsRaw): boolean => { + if (!Object.values(UIBuildType).includes(userArguments.ui!)) return false; + return validateUserArgumentsByFilterStrategy(userArguments, "ui", [ + FILTER_CHOICES_STRATEGY.filterFrontendByUiType, + FILTER_CHOICES_STRATEGY.filterRecipeByUiType, + ]); +}; + +/** + * Retrieves the supported frontend frameworks for a given UI build type. + * + * @param {UIBuildType} ui - The type of UI build. + * @returns {SupportedFrontends[]} An array of supported frontend frameworks for the given UI. + */ +const getSupportedFrontendForUI = (ui: UIBuildType): SupportedFrontends[] => { + const CUSTOM_ONLY: SupportedFrontends[] = ["react-custom"]; + if (ui === UIBuildType.PRE_BUILT) { + // Return all frontends except the custom only ones + return allFrontends.map((frontend) => frontend.id).filter((frontend) => !CUSTOM_ONLY.includes(frontend)); + } + return CUSTOM_ONLY; +}; + +/** + * Retrieves the list of supported recipes for the given UI build type. + * + * @param {UIBuildType} ui - The type of UI build (e.g., PRE_BUILT or CUSTOM). + * @returns {string[]} An array of supported recipe names for the given UI Build type. + */ +const getSupportedRecipeForUI = (ui: UIBuildType): string[] => { + const CUSTOM_SUPPORTED_RECIPES = [ + "emailpassword", + "thirdparty", + "passwordless", + "thirdpartypasswordless", + "thirdpartyemailpassword", + ]; + const PREBUILT_SUPPORTED_RECIPES = [...CUSTOM_SUPPORTED_RECIPES, "all_auth", "multitenancy", "multifactorauth"]; + + return ui === UIBuildType.PRE_BUILT ? PREBUILT_SUPPORTED_RECIPES : CUSTOM_SUPPORTED_RECIPES; +}; diff --git a/lib/ts/index.ts b/lib/ts/index.ts index 2f4c9110..7e496662 100755 --- a/lib/ts/index.ts +++ b/lib/ts/index.ts @@ -6,8 +6,9 @@ import { getDownloadLocationFromAnswers, downloadApp, setupProject, runProjectOr import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { - modifyAnswersBasedOnFlags, + generateInitialAnswers, modifyAnswersBasedOnSelection, + modifyUserArgumentsForAliasFlags, validateUserArguments, } from "./userArgumentUtils.js"; import { Logger } from "./logger.js"; @@ -98,7 +99,8 @@ async function run() { --manager: Which package manager to use --autostart: Whether the CLI should start the project after setting up */ - const userArgumentsRaw = (await yargs(hideBin(process.argv)).argv) as UserFlagsRaw; + let userArgumentsRaw = (await yargs(hideBin(process.argv)).argv) as UserFlagsRaw; + userArgumentsRaw = modifyUserArgumentsForAliasFlags(userArgumentsRaw); validateUserArguments(userArgumentsRaw); const userArguments: UserFlags = { ...userArgumentsRaw, @@ -116,9 +118,9 @@ async function run() { }); // Inquirer prompts all the questions to the user, answers will be an object that contains all the responses - answers = await inquirer.prompt(await getQuestions(userArguments)); + const initialAnswers = generateInitialAnswers(userArguments); + answers = await inquirer.prompt(await getQuestions(userArguments), initialAnswers); - answers = modifyAnswersBasedOnFlags(answers, userArguments); answers = modifyAnswersForPythonFrameworks(answers); answers = modifyAnswersBasedOnSelection(answers); diff --git a/lib/ts/questionUtils.ts b/lib/ts/questionUtils.ts index cb3a4a44..5901d0c4 100644 --- a/lib/ts/questionUtils.ts +++ b/lib/ts/questionUtils.ts @@ -1,5 +1,5 @@ import os from "os"; -import { Answers, QuestionOption, RecipeQuestionOption, UserFlags } from "./types.js"; +import { Answers, QuestionOption, RecipeQuestionOption, UIBuildType, UserFlags } from "./types.js"; export function getPythonRunScripts(): string[] { if (os.platform() === "win32") { @@ -96,3 +96,23 @@ export function shouldSkipBackendQuestion(answers: Answers, userFlags: UserFlags answers.frontend === "sveltekit" ); } + +export function getFrontendPromptMessage(answers: Answers): string { + if (answers.ui === UIBuildType.CUSTOM) { + return ( + "Pick a frontend framework. For other frameworks, check the docs or" + + " select 'Pre-built UI' in the previous 'UI Build Type' step." + ); + } + return "Choose a frontend framework (Visit our documentation for integration with other frameworks):"; +} + +export function getRecipePromptMessage(answers: Answers): string { + if (answers.ui === UIBuildType.CUSTOM) { + return ( + "What type of authentication do you want to use?. For other methods, check the docs or select 'Pre-built UI'" + + " in the previous 'UI Build Type' step." + ); + } + return "What type of authentication do you want to use?"; +} diff --git a/lib/ts/scriptsUtils.ts b/lib/ts/scriptsUtils.ts new file mode 100644 index 00000000..63f78069 --- /dev/null +++ b/lib/ts/scriptsUtils.ts @@ -0,0 +1,76 @@ +import { Answers, UIBuildType } from "./types.js"; +import fs from "fs/promises"; +import path from "path"; + +export async function executeSetupStepsIfExists(directoryPath: string, answers: Answers): Promise { + if (answers.ui === UIBuildType.CUSTOM && answers.frontend === "react-custom") { + await modifyDirectoryCustomReact(directoryPath, answers); + } +} + +async function modifyDirectoryCustomReact(frontendDir: string, answers: Answers): Promise { + const recipe = answers.recipe; + const authPath = path.join(frontendDir, "src/pages/Auth"); + const allItems = await fs.readdir(authPath); + + /** + * Verify if the recipe folder exists + */ + const recipeFolder = allItems.filter((item) => item !== recipe); + if (!recipeFolder || recipeFolder.length === 0) { + throw new Error("recipe folder not found"); + } + + /** + * Get Recipe Dependencies which would be injected into the recipe folder + * Step1: check if the dependency Exists + * Step2: Remove existing folder with the dependency name from the recipe folder + * Step3: Delete the index.tsx file from the dependency folder + * Step4: Copy the dependency folder from the Auth folder to the recipe folder + */ + const dependencies = getRecipeDependencies(recipe); + for (const dependency of dependencies) { + if (!allItems.includes(dependency)) { + throw new Error(`Dependency ${dependency} not found`); + } + const dependencyPath = path.join(authPath, dependency); + const recipeDependencyPath = path.join(authPath, recipe, dependency); + await fs.rm(recipeDependencyPath, { recursive: true }); + await fs.rm(path.join(dependencyPath, "index.tsx")); + await fs.cp(dependencyPath, recipeDependencyPath, { recursive: true }); + } + + /** + * Remove all the folders except the recipe folder + */ + for (const item of allItems) { + if (item === recipe) { + continue; + } + const itemPath = path.join(authPath, item); + await fs.rm(itemPath, { recursive: true }); + } + + /** + * Move all the files from the recipe folder to Auth folder + */ + const allItemsInsideRecipe = await fs.readdir(path.join(authPath, recipe)); + for (const item of allItemsInsideRecipe) { + const itemPath = path.join(authPath, recipe, item); + const newPath = path.join(authPath, item); + await fs.cp(itemPath, newPath, { recursive: true }); + } + + /** + * Remove the recipe folder as part of cleanup + */ + await fs.rm(path.join(authPath, recipe), { recursive: true }); +} + +export function getRecipeDependencies(recipe: string): string[] { + const recipeDependencies: Record = { + thirdpartyemailpassword: ["thirdparty", "emailpassword"], + thirdpartypasswordless: ["thirdparty", "passwordless"], + }; + return recipeDependencies?.[recipe] || []; +} diff --git a/lib/ts/types.ts b/lib/ts/types.ts index fd676bbc..ff06ed5a 100644 --- a/lib/ts/types.ts +++ b/lib/ts/types.ts @@ -29,6 +29,7 @@ export function isValidRecipeName(recipe: string): recipe is Recipe { export type SupportedFrontends = | "react" + | "react-custom" | "next" | "next-multitenancy" | "next-app-directory" @@ -52,6 +53,9 @@ export const allFrontends: { { id: "react", }, + { + id: "react-custom", + }, { id: "next", }, @@ -192,7 +196,10 @@ export type RecipeQuestionOption = { shouldDisplay?: boolean; }; +export type UIBuildTypeOption = RecipeQuestionOption; + export type Answers = { + ui: UIBuildType; frontend?: SupportedFrontends; backend?: SupportedBackends; recipe: string; @@ -216,6 +223,11 @@ export type SupportedPackageManagers = "npm" | "yarn" | "pnpm" | "bun"; export const allPackageManagers: SupportedPackageManagers[] = ["npm", "yarn", "pnpm", "bun"]; +export enum UIBuildType { + CUSTOM = "custom", + PRE_BUILT = "pre-built", +} + export function isValidPackageManager(manager: string): manager is SupportedPackageManagers { if (allPackageManagers.includes(manager as SupportedPackageManagers)) { return true; @@ -232,6 +244,7 @@ export type UserFlagsRaw = { frontend?: SupportedFrontends; backend?: SupportedBackends; manager?: SupportedPackageManagers; + ui?: UIBuildType; autostart?: string | boolean; }; @@ -268,3 +281,13 @@ export type AnalyticsEventWithCommonProperties = AnalyticsEvent & { os: string; cliversion: string; }; + +export type PromptListChoice = { + name: string; + value: string; +}; + +export interface IPromptFilterStrategy { + filterChoices: (choices: PromptListChoice[], answers: Answers) => PromptListChoice[]; + validateUserArguments: (userArguments: UserFlagsRaw) => boolean; +} diff --git a/lib/ts/userArgumentUtils.ts b/lib/ts/userArgumentUtils.ts index a3277f32..2d57f2b1 100644 --- a/lib/ts/userArgumentUtils.ts +++ b/lib/ts/userArgumentUtils.ts @@ -8,11 +8,13 @@ import { isValidFrontend, isValidPackageManager, isValidRecipeName, + UIBuildType, UserFlags, UserFlagsRaw, } from "./types.js"; import validateProjectName from "validate-npm-package-name"; import path from "path"; +import { isValidUiType } from "./filterChoicesUtils.js"; export function validateNpmName(name: string): { valid: boolean; @@ -78,6 +80,16 @@ export function validateUserArguments(userArguments: UserFlagsRaw) { throw new Error("Invalid package manager provided, valid values:\n" + availableManagers); } } + + if (userArguments.ui !== undefined) { + if (!isValidUiType(userArguments)) { + throw new Error( + `Invalid UI type provided, valid values: ${Object.values(UIBuildType).join( + ", " + )} or provided frontend or recipe is not compatible with the UI type` + ); + } + } } export function modifyAnswersBasedOnSelection(answers: Answers): Answers { @@ -102,8 +114,8 @@ export function modifyAnswersBasedOnSelection(answers: Answers): Answers { return _answers; } -export function modifyAnswersBasedOnFlags(answers: Answers, userArguments: UserFlags): Answers { - let _answers = answers; +export function generateInitialAnswers(userArguments: UserFlags): Partial { + let _answers: Partial = {}; if (userArguments.appname !== undefined) { _answers.appname = userArguments.appname; @@ -113,6 +125,10 @@ export function modifyAnswersBasedOnFlags(answers: Answers, userArguments: UserF _answers.recipe = userArguments.recipe; } + if (userArguments.ui !== undefined) { + _answers.ui = userArguments.ui; + } + if (userArguments.frontend !== undefined) { const selectedFrontend = allFrontends.filter((i) => userArguments.frontend === i.id); @@ -146,3 +162,13 @@ export function getShouldAutoStartFromArgs(userArguments: UserFlags): boolean { return false; } + +export function modifyUserArgumentsForAliasFlags(userArguments: UserFlagsRaw): UserFlagsRaw { + let _userArguments = structuredClone(userArguments); + + if (_userArguments.ui === UIBuildType.CUSTOM && _userArguments.frontend === "react") { + _userArguments.frontend = "react-custom"; + } + + return _userArguments; +} diff --git a/lib/ts/utils.ts b/lib/ts/utils.ts index 2d485190..041394e1 100644 --- a/lib/ts/utils.ts +++ b/lib/ts/utils.ts @@ -2,7 +2,7 @@ import { getBackendOptionForProcessing, getFrontendOptionsForProcessing } from " import tar from "tar"; import { promisify } from "util"; import stream from "node:stream"; -import { Answers, DownloadLocations, ExecOutput, QuestionOption, UserFlags } from "./types"; +import { Answers, DownloadLocations, ExecOutput, QuestionOption, UserFlags } from "./types.js"; import fs from "fs"; import path from "path"; import { exec } from "child_process"; @@ -14,6 +14,7 @@ import chalk from "chalk"; import { fileURLToPath } from "url"; import fetch from "node-fetch"; import { addPackageCommand } from "./packageManager.js"; +import { executeSetupStepsIfExists } from "./scriptsUtils.js"; const pipeline = promisify(stream.pipeline); const defaultSetupErrorString = "Project Setup Failed!"; @@ -374,7 +375,12 @@ async function setupFrontendBackendApp( }); } + spinner.text = "Executing setup scripts"; + + await executeSetupStepsIfExists(`./${folderName}/frontend`, answers); + spinner.text = "Installing frontend dependencies"; + const frontendSetup = new Promise((res) => { let stderr: string[] = []; @@ -427,6 +433,7 @@ async function setupFrontendBackendApp( await performAdditionalSetupForFrontendIfNeeded(selectedFrontend, folderName, userArguments); spinner.text = "Installing backend dependencies"; + const backendSetup = new Promise((res) => { let stderr: string[] = [];