diff --git a/examples/with-account-linking/README.md b/examples/with-account-linking/README.md index 06201884d..5a477f2e7 100644 --- a/examples/with-account-linking/README.md +++ b/examples/with-account-linking/README.md @@ -1 +1,55 @@ -# Account linking is not supported yet, we are actively working on the feature. +![SuperTokens banner](https://raw.githubusercontent.com/supertokens/supertokens-logo/master/images/Artboard%20%E2%80%93%2027%402x.png) + +# SuperTokens Google one tap Demo app + +This demo app demonstrates the following use cases: + +- Thirdparty Login / Sign-up +- Email Password Login / Sign-up +- Logout +- Session management & Calling APIs +- Account linking + +## Project setup + +Clone the repo, enter the directory, and use `npm` to install the project dependencies: + +```bash +git clone https://github.com/supertokens/supertokens-auth-react +cd supertokens-auth-react/examples/with-thirdparty-google-onetap +npm install +cd frontend && npm install && cd ../ +cd backend && npm install && cd ../ +``` + +## Run the demo app + +This compiles and serves the React app and starts the backend API server on port 3001. + +```bash +npm run start +``` + +The app will start on `http://localhost:3000` + +## How it works + +TODO + +### On the frontend + +The demo uses the pre-built UI, but you can always build your own UI instead. + +TODO + +### On the backend + +TODO + +## 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-account-linking/backend/config.ts b/examples/with-account-linking/backend/config.ts new file mode 100644 index 000000000..1201af2ee --- /dev/null +++ b/examples/with-account-linking/backend/config.ts @@ -0,0 +1,109 @@ +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; +import Session from "supertokens-node/recipe/session"; +import Passwordless from "supertokens-node/recipe/passwordless"; +import AccountLinking from "supertokens-node/recipe/accountlinking"; +import EmailVerification from "supertokens-node/recipe/emailverification"; +import { TypeInput } from "supertokens-node/types"; +import UserMetadata from "supertokens-node/recipe/usermetadata"; +import Dashboard from "supertokens-node/recipe/dashboard"; +import { getUser } from "supertokens-node"; + +export function getApiDomain() { + const apiPort = process.env.REACT_APP_API_PORT || 3001; + const apiUrl = process.env.REACT_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = process.env.REACT_APP_WEBSITE_PORT || 3000; + const websiteUrl = process.env.REACT_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const SuperTokensConfig: TypeInput = { + supertokens: { + // this is the location of the SuperTokens core. + connectionURI: "http://localhost:3567", + }, + appInfo: { + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + websiteDomain: getWebsiteDomain(), + }, + // recipeList contains all the modules that you want to + // use from SuperTokens. See the full list here: https://supertokens.com/docs/guides + recipeList: [ + EmailVerification.init({ + mode: "REQUIRED", + }), + UserMetadata.init(), + AccountLinking.init({ + shouldDoAutomaticAccountLinking: async (_newInfo, _user, _tenantId, context) => { + if (context.doNotLink === true) { + return { + shouldAutomaticallyLink: false, + }; + } + return { + shouldAutomaticallyLink: true, + shouldRequireVerification: true, + }; + }, + }), + ThirdPartyEmailPassword.init({ + providers: [ + // We have provided you with development keys which you can use for testing. + // IMPORTANT: Please replace them with your own OAuth keys for production use. + { + config: { + thirdPartyId: "google", + clients: [ + { + clientId: "1060725074195-kmeum4crr01uirfl2op9kd5acmi9jutn.apps.googleusercontent.com", + clientSecret: "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", + }, + ], + }, + }, + { + config: { + thirdPartyId: "github", + clients: [ + { + clientId: "467101b197249757c71f", + clientSecret: "e97051221f4b6426e8fe8d51486396703012f5bd", + }, + ], + }, + }, + { + 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", + }, + }, + ], + }, + }, + ], + override: { + apis: (oI) => ({ + ...oI, + }), + }, + }), + Passwordless.init({ + contactMethod: "PHONE", + flowType: "USER_INPUT_CODE_AND_MAGIC_LINK", + }), + Session.init(), + Dashboard.init(), + ], +}; diff --git a/examples/with-account-linking/backend/index.ts b/examples/with-account-linking/backend/index.ts new file mode 100644 index 000000000..1810c15ec --- /dev/null +++ b/examples/with-account-linking/backend/index.ts @@ -0,0 +1,304 @@ +import express from "express"; +import cors from "cors"; +import supertokens, { getUser, listUsersByAccountInfo } from "supertokens-node"; +import { verifySession } from "supertokens-node/recipe/session/framework/express"; +import { middleware, errorHandler, SessionRequest } from "supertokens-node/framework/express"; +import { getWebsiteDomain, SuperTokensConfig } from "./config"; +import EmailVerification from "supertokens-node/recipe/emailverification"; +import AccountLinking from "supertokens-node/recipe/accountlinking"; +import Session from "supertokens-node/recipe/session"; +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; +import Passwordless from "supertokens-node/recipe/passwordless"; + +supertokens.init(SuperTokensConfig); + +const app = express(); + +app.use( + cors({ + origin: getWebsiteDomain(), + allowedHeaders: ["content-type", ...supertokens.getAllCORSHeaders()], + methods: ["GET", "PUT", "POST", "DELETE"], + credentials: true, + }) +); + +// This exposes all the APIs from SuperTokens to the client. +app.use(middleware()); +app.use(express.json()); + +// An example API that requires session verification +app.get("/sessioninfo", verifySession(), async (req: SessionRequest, res) => { + let session = req.session; + res.send({ + sessionHandle: session!.getHandle(), + userId: session!.getUserId(), + accessTokenPayload: session!.getAccessTokenPayload(), + }); +}); + +app.get("/userInfo", verifySession(), async (req: SessionRequest, res) => { + const session = req.session!; + const user = await getUser(session.getRecipeUserId().getAsString()); + if (!user) { + throw new Session.Error({ type: Session.Error.UNAUTHORISED, message: "user removed" }); + } + + res.json({ + user: user.toJson(), + }); +}); + +app.post("/addPassword", verifySession(), async (req: SessionRequest, res) => { + const session = req.session!; + const user = await getUser(session.getRecipeUserId().getAsString()); + if (!user) { + throw new Session.Error({ type: Session.Error.UNAUTHORISED, message: "user removed" }); + } + const loginMethod = user.loginMethods.find( + (m) => m.recipeUserId.getAsString() === session.getRecipeUserId().getAsString() + ); + if (!loginMethod) { + throw new Error("This should never happen"); + } + + if (loginMethod.recipeId === "emailpassword") { + return res.json({ + status: "GENERAL_ERROR", + message: "This user already has a password associated to it", + }); + } + + if (!loginMethod.verified) { + return res.json({ + status: "GENERAL_ERROR", + message: "You can only add a password when logged in using a verified account", + }); + } + + // Technically we do not need this limitation + if (loginMethod.email === undefined) { + return res.json({ + status: "GENERAL_ERROR", + message: "You can only add a password when to accounts associated with email addresses", + }); + } + + let password: string = req.body.password; + + const signUpResp = await ThirdPartyEmailPassword.emailPasswordSignUp( + session.getTenantId(), + loginMethod.email, + password + ); + + if (signUpResp.status === "EMAIL_ALREADY_EXISTS_ERROR") { + // This is an edge-case where the current third-party user has an email that has already signed up but not linked for some reason. + return res.json({ + status: "GENERAL_ERROR", + message: "This user has already signed up. Please delete it first.", + }); + } + + if (signUpResp.status !== "OK") { + return res.json(signUpResp); + } + // Here we can assume the user in signUpResp is not a primary user since it was just created + // Plus the linkAccounts core impl checks anyway + const newRecipeUserId = signUpResp.user.loginMethods[0].recipeUserId; + + const tokenResp = await EmailVerification.createEmailVerificationToken( + session.getTenantId(), + newRecipeUserId, + loginMethod.email + ); + if (tokenResp.status === "OK") { + await EmailVerification.verifyEmailUsingToken(session.getTenantId(), tokenResp.token, false); + } + + const linkResp = await AccountLinking.linkAccounts(newRecipeUserId, session.getUserId()); + if (linkResp.status !== "OK") { + return res.json({ + status: "GENERAL_ERROR", + message: linkResp.status, // TODO: proper string + }); + } + // if the access token payload contains any information that'd change based on the new account, we'd want to update it here. + + return res.json({ + status: "OK", + user: linkResp.user, + }); +}); + +app.post("/addThirdPartyUser", verifySession(), async (req: SessionRequest, res) => { + // We need this because several functions below require it + const userContext = {}; + const session = req.session!; + const user = await getUser(session.getRecipeUserId().getAsString()); + if (!user) { + throw new Session.Error({ type: Session.Error.UNAUTHORISED, message: "user removed" }); + } + const loginMethod = user.loginMethods.find( + (m) => m.recipeUserId.getAsString() === session.getRecipeUserId().getAsString() + ); + if (!loginMethod) { + throw new Error("This should never happen"); + } + + if (!loginMethod.verified) { + return res.json({ + status: "GENERAL_ERROR", + message: "You can only add a password when logged in using a verified account", + }); + } + + const provider = await ThirdPartyEmailPassword.thirdPartyGetProvider( + session.getTenantId(), + req.body.thirdPartyId, + req.body.clientType + ); + if (provider === undefined) { + return res.json({ + status: "GENERAL_ERROR", + message: "Unknown thirdparty provider id/client type", + }); + } + + let oAuthTokensToUse; + if ("redirectURIInfo" in req.body && req.body.redirectURIInfo !== undefined) { + oAuthTokensToUse = await provider.exchangeAuthCodeForOAuthTokens({ + redirectURIInfo: req.body.redirectURIInfo, + userContext, + }); + } else if ("oAuthTokens" in req.body && req.body.oAuthTokens !== undefined) { + oAuthTokensToUse = req.body.oAuthTokens; + } else { + throw Error("should never come here"); + } + const tpUserInfo = await provider.getUserInfo({ oAuthTokens: oAuthTokensToUse, userContext }); + let emailInfo = tpUserInfo.email; + if (emailInfo === undefined) { + return res.json({ + status: "NO_EMAIL_GIVEN_BY_PROVIDER", + }); + } + + if (!user.emails.includes(emailInfo.id) && !emailInfo.isVerified) { + return res.json({ + status: "GENERAL_ERROR", + message: "The email of the third-party account doesn't match the current user and is not verified", + }); + } + const signUpResp = await ThirdPartyEmailPassword.thirdPartyManuallyCreateOrUpdateUser( + session.getTenantId(), + req.body.thirdPartyId, + tpUserInfo.thirdPartyUserId, + emailInfo.id, + emailInfo.isVerified, + { doNotLink: true } + ); + + if (signUpResp.status !== "OK") { + return res.json(signUpResp); + } + + if (!signUpResp.createdNewRecipeUser) { + return res.json({ + status: "GENERAL_ERROR", + message: "This user has already signed up. Please delete it first.", + }); + } + // Here we can assume the user in signUpResp is not a primary user since it was just created + // Plus the linkAccounts core impl checks anyway + const newRecipeUserId = signUpResp.user.loginMethods[0].recipeUserId; + + const tokenResp = await EmailVerification.createEmailVerificationToken( + session.getTenantId(), + newRecipeUserId, + loginMethod.email + ); + if (tokenResp.status === "OK") { + await EmailVerification.verifyEmailUsingToken(session.getTenantId(), tokenResp.token, false); + } + + const linkResp = await AccountLinking.linkAccounts(newRecipeUserId, session.getUserId()); + if (linkResp.status !== "OK") { + return res.json({ + status: "GENERAL_ERROR", + message: linkResp.status, // TODO: proper string + }); + } + // if the access token payload contains any information that'd change based on the new account, we'd want to update it here. + + return res.json({ + status: "OK", + user: linkResp.user, + }); +}); + +app.post("/addPhoneNumber", verifySession(), async (req: SessionRequest, res) => { + const session = req.session!; + const user = await getUser(session.getRecipeUserId().getAsString()); + if (!user) { + throw new Session.Error({ type: Session.Error.UNAUTHORISED, message: "user removed" }); + } + const loginMethod = user.loginMethods.find( + (m) => m.recipeUserId.getAsString() === session.getRecipeUserId().getAsString() + ); + if (!loginMethod) { + throw new Error("This should never happen"); + } + + if (!loginMethod.verified) { + return res.json({ + status: "GENERAL_ERROR", + message: "You can only add a phone number when logged in using a verified account", + }); + } + + const phoneNumber = req.body.phoneNumber; + + const otherUsers = await listUsersByAccountInfo("public", { phoneNumber }); + if (otherUsers.length > 0) { + return res.json({ + status: "GENERAL_ERROR", + message: "You can only add a phone number to a single user", + }); + } + + const signUpResp = await Passwordless.signInUp({ + tenantId: session.getTenantId(), + phoneNumber, + userContext: { doNotLink: true }, + }); + + if (signUpResp.createdNewRecipeUser === false) { + // This is possible only in a race-condition where 2 users are adding the same phone number. + return res.json({ + status: "GENERAL_ERROR", + message: "You can only add a phone number to a single user", + }); + } + const newRecipeUserId = signUpResp.user.loginMethods[0].recipeUserId; + + const linkResp = await AccountLinking.linkAccounts(newRecipeUserId, session.getUserId()); + if (linkResp.status !== "OK") { + return res.json({ + status: "GENERAL_ERROR", + message: linkResp.status, // TODO: proper string + }); + } + // if the access token payload contains any information that'd change based on the new account, we'd want to update it here. + + return res.json({ + status: "OK", + user: linkResp.user, + }); +}); + +// In case of session related errors, this error handler +// returns 401 to the client. +app.use(errorHandler()); + +app.listen(3001, () => console.log(`API Server listening on port 3001`)); diff --git a/examples/with-account-linking/backend/package.json b/examples/with-account-linking/backend/package.json new file mode 100644 index 000000000..59a4adeee --- /dev/null +++ b/examples/with-account-linking/backend/package.json @@ -0,0 +1,30 @@ +{ + "name": "supertokens-node", + "version": "0.0.1", + "private": true, + "description": "", + "main": "index.js", + "scripts": { + "start": "npx ts-node-dev --project ./tsconfig.json ./index.ts" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.1", + "helmet": "^5.1.0", + "morgan": "^1.10.0", + "npm-run-all": "^4.1.5", + "supertokens-node": "github:supertokens/supertokens-node#account-linking", + "ts-node-dev": "^2.0.0", + "typescript": "^4.7.2" + }, + "devDependencies": { + "@types/cors": "^2.8.12", + "@types/express": "^4.17.17", + "@types/morgan": "^1.9.3", + "@types/node": "^16.11.38", + "nodemon": "^2.0.16" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/examples/with-account-linking/backend/tsconfig.json b/examples/with-account-linking/backend/tsconfig.json new file mode 100644 index 000000000..8a91acaae --- /dev/null +++ b/examples/with-account-linking/backend/tsconfig.json @@ -0,0 +1,62 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, + "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + // "outDir": "./", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + /* Advanced Options */ + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } +} diff --git a/examples/with-account-linking/frontend/.env b/examples/with-account-linking/frontend/.env new file mode 100644 index 000000000..7d910f148 --- /dev/null +++ b/examples/with-account-linking/frontend/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true \ No newline at end of file diff --git a/examples/with-account-linking/frontend/.gitignore b/examples/with-account-linking/frontend/.gitignore new file mode 100644 index 000000000..4d29575de --- /dev/null +++ b/examples/with-account-linking/frontend/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/with-account-linking/frontend/LICENSE.md b/examples/with-account-linking/frontend/LICENSE.md new file mode 100644 index 000000000..588f27e68 --- /dev/null +++ b/examples/with-account-linking/frontend/LICENSE.md @@ -0,0 +1,192 @@ +Copyright (c) 2020, 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 software except in compliance with the License. A copy +of the License is available below the line. + +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. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/examples/with-account-linking/frontend/package.json b/examples/with-account-linking/frontend/package.json new file mode 100644 index 000000000..cd6e6e145 --- /dev/null +++ b/examples/with-account-linking/frontend/package.json @@ -0,0 +1,46 @@ +{ + "name": "supertokens-react", + "version": "0.1.0", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.11.56", + "@types/react": "^18.0.18", + "@types/react-dom": "^18.0.6", + "axios": "^0.21.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.2.1", + "react-scripts": "5.0.1", + "supertokens-auth-react": "github:supertokens/supertokens-auth-react#feat/account-linking", + "typescript": "^4.8.2", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/with-account-linking/frontend/public/favicon.ico b/examples/with-account-linking/frontend/public/favicon.ico new file mode 100644 index 000000000..a11777cc4 Binary files /dev/null and b/examples/with-account-linking/frontend/public/favicon.ico differ diff --git a/examples/with-account-linking/frontend/public/index.html b/examples/with-account-linking/frontend/public/index.html new file mode 100644 index 000000000..6f1f7cb51 --- /dev/null +++ b/examples/with-account-linking/frontend/public/index.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + React App + + + +
+ + diff --git a/examples/with-account-linking/frontend/public/manifest.json b/examples/with-account-linking/frontend/public/manifest.json new file mode 100644 index 000000000..f01493ff0 --- /dev/null +++ b/examples/with-account-linking/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/with-account-linking/frontend/public/robots.txt b/examples/with-account-linking/frontend/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/examples/with-account-linking/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/examples/with-account-linking/frontend/src/App.css b/examples/with-account-linking/frontend/src/App.css new file mode 100644 index 000000000..8a98a2341 --- /dev/null +++ b/examples/with-account-linking/frontend/src/App.css @@ -0,0 +1,27 @@ +.App { + display: flex; + flex-direction: column; + width: 100vw; + height: 100vh; + font-family: Rubik; +} + +.fill { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; +} + +.sessionButton { + padding-left: 13px; + padding-right: 13px; + padding-top: 8px; + padding-bottom: 8px; + background-color: black; + border-radius: 10px; + cursor: pointer; + color: white; + font-weight: bold; + font-size: 17px; +} diff --git a/examples/with-account-linking/frontend/src/App.tsx b/examples/with-account-linking/frontend/src/App.tsx new file mode 100644 index 000000000..8786f716b --- /dev/null +++ b/examples/with-account-linking/frontend/src/App.tsx @@ -0,0 +1,64 @@ +import "./App.css"; +import SuperTokens, { SuperTokensWrapper } from "supertokens-auth-react"; +import { getSuperTokensRoutesForReactRouterDom } from "supertokens-auth-react/ui"; +import { SessionAuth } from "supertokens-auth-react/recipe/session"; +import { Routes, BrowserRouter as Router, Route } from "react-router-dom"; +import Home from "./Home"; +import { PreBuiltUIList, SuperTokensConfig } from "./config"; +import { LinkingPage } from "./LinkingPage"; +import { LinkingCallbackPage } from "./LinkingCallbackPage"; + +SuperTokens.init(SuperTokensConfig); + +function App() { + return ( + +
+ +
+ + {/* This shows the login UI on "/auth" route */} + {getSuperTokensRoutesForReactRouterDom(require("react-router-dom"), PreBuiltUIList)} + + only if the user is logged in. + Else it redirects the user to "/auth" */ + + + + } + /> + only if the user is logged in. + Else it redirects the user to "/auth" */ + + + + } + /> + only if the user is logged in. + Else it redirects the user to "/auth" */ + + + + } + /> + +
+
+
+
+ ); +} + +export default App; diff --git a/examples/with-account-linking/frontend/src/Home/CallAPIView.tsx b/examples/with-account-linking/frontend/src/Home/CallAPIView.tsx new file mode 100644 index 000000000..6a9d510d4 --- /dev/null +++ b/examples/with-account-linking/frontend/src/Home/CallAPIView.tsx @@ -0,0 +1,15 @@ +import axios from "axios"; +import { getApiDomain } from "../config"; + +export default function CallAPIView() { + async function callAPIClicked() { + let response = await axios.get(getApiDomain() + "/sessioninfo"); + window.alert("Session Information:\n" + JSON.stringify(response.data, null, 2)); + } + + return ( +
+ Call API +
+ ); +} diff --git a/examples/with-account-linking/frontend/src/Home/Home.css b/examples/with-account-linking/frontend/src/Home/Home.css new file mode 100644 index 000000000..228e600d4 --- /dev/null +++ b/examples/with-account-linking/frontend/src/Home/Home.css @@ -0,0 +1,191 @@ +@font-face { + font-family: Menlo; + src: url("../assets/fonts/MenloRegular.ttf"); +} + +.app-container { + font-family: Rubik, sans-serif; +} + +.app-container * { + box-sizing: border-box; +} + +.bold-400 { + font-variation-settings: "wght" 400; +} + +.bold-500 { + font-variation-settings: "wght" 500; +} + +.bold-600 { + font-variation-settings: "wght" 600; +} + +#home-container { + align-items: center; + min-height: 100vh; + background: url("../assets/images/background.png"); + background-size: cover; +} + +.bold-700 { + font-variation-settings: "wght" 700; +} + +.app-container .main-container { + 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; +} + +.main-container .success-title { + 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; +} + +.success-title img.success-icon { + margin-right: 8px; +} + +.main-container .inner-content { + padding-block: 48px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.inner-content #user-id { + 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; +} + +.inner-content #user-id:before { + content: ""; + position: absolute; + inset: 0; + border-radius: 9px; + padding: 2px; + background: linear-gradient(90.31deg, #ff9933 0.11%, #ff3f33 99.82%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +.main-container > .top-band, +.main-container > .bottom-band { + border-radius: inherit; +} + +.main-container .top-band { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.main-container .bottom-band { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.main-container .sessionButton { + 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; + margin: 0.5em; + text-decoration: none; +} + +.bottom-cta-container { + display: flex; + justify-content: flex-end; + padding-inline: 21px; + background-color: #212d4f; +} + +.bottom-cta-container .view-code { + padding-block: 11px; + color: #bac9f5; + cursor: pointer; + font-size: 14px; +} + +.bottom-links-container { + display: grid; + grid-template-columns: repeat(4, auto); + margin-bottom: 22px; +} + +.bottom-links-container .link { + display: flex; + align-items: center; + margin-inline-end: 68px; + cursor: pointer; +} + +.bottom-links-container .link:last-child { + margin-right: 0; +} + +.truncate { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.separator-line { + max-width: 100%; +} + +.link .link-icon { + width: 15px; + margin-right: 5px; +} + +@media screen and (max-width: 768px) { + .bottom-links-container { + grid-template-columns: repeat(2, 1fr); + column-gap: 64px; + row-gap: 34px; + } + + .bottom-links-container .link { + margin-inline-end: 0; + } + + .separator-line { + max-width: 200px; + } +} + +@media screen and (max-width: 480px) { + #home-container { + justify-content: start; + padding-block-start: 25px; + } + + .app-container .main-container { + margin-block-end: 90px; + } +} diff --git a/examples/with-account-linking/frontend/src/Home/SuccessView.tsx b/examples/with-account-linking/frontend/src/Home/SuccessView.tsx new file mode 100644 index 000000000..5a5417afe --- /dev/null +++ b/examples/with-account-linking/frontend/src/Home/SuccessView.tsx @@ -0,0 +1,78 @@ +import { NavLink, useNavigate } from "react-router-dom"; +import { signOut } from "supertokens-auth-react/recipe/session"; +import { recipeDetails } from "../config"; +import CallAPIView from "./CallAPIView"; +import { BlogsIcon, CelebrateIcon, GuideIcon, SeparatorLine, SignOutIcon } from "../assets/images"; + +interface ILink { + name: string; + onClick: () => void; + icon: string; +} + +export default function SuccessView(props: { userId: string }) { + let userId = props.userId; + + const navigate = useNavigate(); + + async function logoutClicked() { + await signOut(); + navigate("/auth"); + } + + function openLink(url: string) { + window.open(url, "_blank"); + } + + const links: ILink[] = [ + { + name: "Blogs", + onClick: () => openLink("https://supertokens.com/blog"), + icon: BlogsIcon, + }, + { + name: "Documentation", + onClick: () => openLink(recipeDetails.docsLink), + icon: GuideIcon, + }, + { + name: "Sign Out", + onClick: logoutClicked, + icon: SignOutIcon, + }, + ]; + + return ( + <> +
+
+ Login successful Login successful +
+
+
Your userID is:
+
+ {userId} +
+
+ + + + Manage login Methods + +
+
+
+
+ {links.map((link) => ( +
+ {link.name} +
+ {link.name} +
+
+ ))} +
+ separator + + ); +} diff --git a/examples/with-account-linking/frontend/src/Home/index.tsx b/examples/with-account-linking/frontend/src/Home/index.tsx new file mode 100644 index 000000000..0c3f288e8 --- /dev/null +++ b/examples/with-account-linking/frontend/src/Home/index.tsx @@ -0,0 +1,17 @@ +import SuccessView from "./SuccessView"; +import { useSessionContext } from "supertokens-auth-react/recipe/session"; +import "./Home.css"; + +export default function Home() { + const sessionContext = useSessionContext(); + + if (sessionContext.loading === true) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/examples/with-account-linking/frontend/src/LinkingCallbackPage/index.tsx b/examples/with-account-linking/frontend/src/LinkingCallbackPage/index.tsx new file mode 100644 index 000000000..e558352e1 --- /dev/null +++ b/examples/with-account-linking/frontend/src/LinkingCallbackPage/index.tsx @@ -0,0 +1,37 @@ +import ThirdPartyEmailPassword from "supertokens-auth-react/recipe/thirdpartyemailpassword"; +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { useAsyncCallOnMount } from "../useAsyncCallOnMount"; + +export const LinkingCallbackPage: React.FC = () => { + const navigate = useNavigate(); + + useAsyncCallOnMount( + () => + ThirdPartyEmailPassword.thirdPartySignInAndUp({ + options: { + preAPIHook: async (input) => { + const url = new URL(input.url); + url.pathname = "/addThirdPartyUser"; + input.url = url.toString(); + return input; + }, + }, + }), + (resp) => { + if (resp.status === "OK") { + navigate(`/link?success=${encodeURIComponent("Successfully added account")}`); + } else if ("reason" in resp) { + navigate(`/link?error=${encodeURIComponent(resp.reason)}`); + } else { + navigate(`/link?error=${encodeURIComponent(resp.status)}`); + } + } + ); + + return ( +
+ Linking... +
+ ); +}; diff --git a/examples/with-account-linking/frontend/src/LinkingPage/index.tsx b/examples/with-account-linking/frontend/src/LinkingPage/index.tsx new file mode 100644 index 000000000..6fd65b1b8 --- /dev/null +++ b/examples/with-account-linking/frontend/src/LinkingPage/index.tsx @@ -0,0 +1,140 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { NavLink, useLocation } from "react-router-dom"; +import { useSessionContext } from "supertokens-auth-react/recipe/session"; +import { redirectToThirdPartyLogin } from "supertokens-auth-react/recipe/thirdpartyemailpassword"; + +import { getApiDomain } from "../config"; +import "./styles.css"; + +export const LinkingPage: React.FC = () => { + const location = useLocation(); + const sessionContext = useSessionContext(); + + const [userInfo, setUserInfo] = useState(); + + const [error, setError] = useState(new URLSearchParams(location.search).get("error")); + const [success, setSuccess] = useState(new URLSearchParams(location.search).get("success")); + + const [phoneNumber, setPhoneNumber] = useState(); + const [password, setPassword] = useState(); + + const loadUserInfo = useCallback(async () => { + const res = await fetch(`${getApiDomain()}/userInfo`); + setUserInfo(await res.json()); + }, [setUserInfo]); + + useEffect(() => { + loadUserInfo(); + }, [loadUserInfo]); + + if (sessionContext.loading === true) { + return null; + } + + let passwordLoginMethods = userInfo?.user.loginMethods.filter((lm: any) => lm.recipeId === "emailpassword"); + let thirdPartyLoginMethod = userInfo?.user.loginMethods.filter((lm: any) => lm.recipeId === "thirdparty"); + let phoneLoginMethod = userInfo?.user.loginMethods.filter((lm: any) => lm.recipeId === "passwordless"); + + return ( +
+ Back + {error &&
{error}
} + {success &&
{success}
} + + {userInfo === undefined ? ( +
Login methods loading...
+ ) : ( +
    + {passwordLoginMethods.map((lm: any) => ( +
    + {lm.recipeId} + {lm.recipeUserId} + Email: {lm.email} +
    + ))} + {thirdPartyLoginMethod.map((lm: any) => ( +
    + {lm.recipeId} + {lm.recipeUserId} + + {" "} + Provider: {lm.thirdParty.id} - Email: {lm.email} + +
    + ))} + {phoneLoginMethod.map((lm: any) => ( +
    + {lm.recipeId} + {lm.recipeUserId} + Phone number: {lm.phoneNumber} +
    + ))} +
+ )} + {passwordLoginMethods?.length === 0 && ( +
{ + fetch(`${getApiDomain()}/addPassword`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + password, + }), + }).then((resp) => { + resp.json().then((body) => { + if (body.status !== "OK") { + setError(body.reason ?? body.message ?? body.status); + } else { + setSuccess("Successfully added password"); + } + }); + }); + ev.preventDefault(); + return false; + }}> + setPassword(ev.currentTarget.value)}> + +
+ )} + {phoneLoginMethod?.length === 0 && ( +
{ + fetch(`${getApiDomain()}/addPhoneNumber`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + phoneNumber, + }), + }).then((resp) => { + resp.json().then((body) => { + if (body.status !== "OK") { + setError(body.reason ?? body.message ?? body.status); + } else { + setSuccess("Successfully added phone number"); + } + }); + loadUserInfo(); + setPhoneNumber(""); + setPassword(""); + }); + ev.preventDefault(); + return false; + }}> + setPhoneNumber(ev.currentTarget.value)}> + +
+ )} + {thirdPartyLoginMethod?.length === 0 && ( +
+ +
+ )} +
+ ); +}; diff --git a/examples/with-account-linking/frontend/src/LinkingPage/styles.css b/examples/with-account-linking/frontend/src/LinkingPage/styles.css new file mode 100644 index 000000000..643f6b4dd --- /dev/null +++ b/examples/with-account-linking/frontend/src/LinkingPage/styles.css @@ -0,0 +1,62 @@ +.success-message { + 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; + margin: 8px; + padding: 16px; + border-radius: 16px; +} + +.error-message { + line-height: 1; + padding-block: 26px; + background-color: #ef9a9a; + text-align: center; + color: black; + display: flex; + justify-content: center; + align-items: flex-end; + font-size: 20px; + margin: 8px; + padding: 16px; + border-radius: 16px; +} + +.login-method { + padding: 1em; + background-color: white; + margin: 1.25em; + display: grid; + grid-template-columns: 8em auto; + border-radius: 1em; +} + +.contactInfo { + grid-column: 1 / 3; + margin-top: 0.5em; +} + +.userId { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +form { + margin: 1.25em; +} + +button { + box-sizing: border-box; + border-radius: 6px; + font-size: 16px; + margin: 0.5em; + border: 1px solid black; + text-decoration: none; +} diff --git a/examples/with-account-linking/frontend/src/assets/fonts/MenloRegular.ttf b/examples/with-account-linking/frontend/src/assets/fonts/MenloRegular.ttf new file mode 100644 index 000000000..033dc6d21 Binary files /dev/null and b/examples/with-account-linking/frontend/src/assets/fonts/MenloRegular.ttf differ diff --git a/examples/with-account-linking/frontend/src/assets/images/arrow-right-icon.svg b/examples/with-account-linking/frontend/src/assets/images/arrow-right-icon.svg new file mode 100644 index 000000000..95aa1fec6 --- /dev/null +++ b/examples/with-account-linking/frontend/src/assets/images/arrow-right-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-account-linking/frontend/src/assets/images/background.png b/examples/with-account-linking/frontend/src/assets/images/background.png new file mode 100644 index 000000000..2147c15c2 Binary files /dev/null and b/examples/with-account-linking/frontend/src/assets/images/background.png differ diff --git a/examples/with-account-linking/frontend/src/assets/images/blogs-icon.svg b/examples/with-account-linking/frontend/src/assets/images/blogs-icon.svg new file mode 100644 index 000000000..a2fc9dd62 --- /dev/null +++ b/examples/with-account-linking/frontend/src/assets/images/blogs-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-account-linking/frontend/src/assets/images/celebrate-icon.svg b/examples/with-account-linking/frontend/src/assets/images/celebrate-icon.svg new file mode 100644 index 000000000..3b40b1efa --- /dev/null +++ b/examples/with-account-linking/frontend/src/assets/images/celebrate-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/examples/with-account-linking/frontend/src/assets/images/guide-icon.svg b/examples/with-account-linking/frontend/src/assets/images/guide-icon.svg new file mode 100644 index 000000000..bd85af72b --- /dev/null +++ b/examples/with-account-linking/frontend/src/assets/images/guide-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-account-linking/frontend/src/assets/images/index.ts b/examples/with-account-linking/frontend/src/assets/images/index.ts new file mode 100644 index 000000000..7adf036c4 --- /dev/null +++ b/examples/with-account-linking/frontend/src/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-account-linking/frontend/src/assets/images/separator-line.svg b/examples/with-account-linking/frontend/src/assets/images/separator-line.svg new file mode 100644 index 000000000..7127a00dc --- /dev/null +++ b/examples/with-account-linking/frontend/src/assets/images/separator-line.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/examples/with-account-linking/frontend/src/assets/images/sign-out-icon.svg b/examples/with-account-linking/frontend/src/assets/images/sign-out-icon.svg new file mode 100644 index 000000000..6cc4f85fd --- /dev/null +++ b/examples/with-account-linking/frontend/src/assets/images/sign-out-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/with-account-linking/frontend/src/config.tsx b/examples/with-account-linking/frontend/src/config.tsx new file mode 100644 index 000000000..866ae8a50 --- /dev/null +++ b/examples/with-account-linking/frontend/src/config.tsx @@ -0,0 +1,62 @@ +import ThirdPartyEmailPassword, { Google, Github, Apple } from "supertokens-auth-react/recipe/thirdpartyemailpassword"; +import EmailVerification from "supertokens-auth-react/recipe/emailverification"; +import { ThirdPartyEmailPasswordPreBuiltUI } from "supertokens-auth-react/recipe/thirdpartyemailpassword/prebuiltui"; +import { EmailVerificationPreBuiltUI } from "supertokens-auth-react/recipe/emailverification/prebuiltui"; +import Session from "supertokens-auth-react/recipe/session"; + +export function getApiDomain() { + const apiPort = process.env.REACT_APP_API_PORT || 3001; + const apiUrl = process.env.REACT_APP_API_URL || `http://localhost:${apiPort}`; + return apiUrl; +} + +export function getWebsiteDomain() { + const websitePort = process.env.REACT_APP_WEBSITE_PORT || 3000; + const websiteUrl = process.env.REACT_APP_WEBSITE_URL || `http://localhost:${websitePort}`; + return websiteUrl; +} + +export const SuperTokensConfig = { + appInfo: { + appName: "SuperTokens Demo App", + apiDomain: getApiDomain(), + websiteDomain: getWebsiteDomain(), + }, + // recipeList contains all the modules that you want to + // use from SuperTokens. See the full list here: https://supertokens.com/docs/guides + recipeList: [ + EmailVerification.init({ + mode: "REQUIRED", + }), + ThirdPartyEmailPassword.init({ + signInAndUpFeature: { + providers: [ + Github.init({ + getRedirectURL: (id) => { + if (window.location.pathname.startsWith("/link")) { + return `${getWebsiteDomain()}/link/tpcallback/${id}`; + } + return `${getWebsiteDomain()}/auth/callback/${id}`; + }, + }), + Google.init({ + getRedirectURL: (id) => { + if (window.location.pathname.startsWith("/link")) { + return `${getWebsiteDomain()}/link/tpcallback/${id}`; + } + return `${getWebsiteDomain()}/auth/callback/${id}`; + }, + }), + Apple.init(), + ], + }, + }), + Session.init(), + ], +}; + +export const recipeDetails = { + docsLink: "https://supertokens.com/docs/thirdpartyemailpassword/introduction", +}; + +export const PreBuiltUIList = [ThirdPartyEmailPasswordPreBuiltUI, EmailVerificationPreBuiltUI]; diff --git a/examples/with-account-linking/frontend/src/index.css b/examples/with-account-linking/frontend/src/index.css new file mode 100644 index 000000000..04146b5e7 --- /dev/null +++ b/examples/with-account-linking/frontend/src/index.css @@ -0,0 +1,11 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", + "Droid Sans", "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} diff --git a/examples/with-account-linking/frontend/src/index.tsx b/examples/with-account-linking/frontend/src/index.tsx new file mode 100644 index 000000000..399c737cd --- /dev/null +++ b/examples/with-account-linking/frontend/src/index.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import App from "./App"; + +const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); +root.render( + + + +); diff --git a/examples/with-account-linking/frontend/src/react-app-env.d.ts b/examples/with-account-linking/frontend/src/react-app-env.d.ts new file mode 100644 index 000000000..6431bc5fc --- /dev/null +++ b/examples/with-account-linking/frontend/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/with-account-linking/frontend/src/useAsyncCallOnMount.ts b/examples/with-account-linking/frontend/src/useAsyncCallOnMount.ts new file mode 100644 index 000000000..a619b8561 --- /dev/null +++ b/examples/with-account-linking/frontend/src/useAsyncCallOnMount.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef } from "react"; + +export const useAsyncCallOnMount = ( + func: () => Promise, + handler: (res: T) => void, + errorHandler?: (err: any) => void +) => { + const signInUpPromise = useRef | undefined>(undefined); + + useEffect(() => { + if (signInUpPromise.current === undefined) { + signInUpPromise.current = func(); + } + const abort = new AbortController(); + + signInUpPromise.current.then( + (resp) => { + if (abort.signal.aborted) { + return; + } + handler(resp); + }, + (err) => { + if (abort.signal.aborted) { + return; + } + if (errorHandler !== undefined) { + errorHandler(err); + } + } + ); + + return () => abort.abort(); + }, [handler, errorHandler]); +}; diff --git a/examples/with-account-linking/frontend/tsconfig.json b/examples/with-account-linking/frontend/tsconfig.json new file mode 100644 index 000000000..c0555cbc6 --- /dev/null +++ b/examples/with-account-linking/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/examples/with-account-linking/package.json b/examples/with-account-linking/package.json new file mode 100644 index 000000000..55d8979fe --- /dev/null +++ b/examples/with-account-linking/package.json @@ -0,0 +1,20 @@ +{ + "name": "with-account-linking", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "start:frontend": "cd frontend && npm run start", + "start:frontend-live-demo-app": "cd frontend && npx serve -s build", + "start:backend": "cd backend && npm run start", + "start:backend-live-demo-app": "cd backend && ./startLiveDemoApp.sh", + "start": "npm-run-all --parallel start:frontend start:backend", + "start-live-demo-app": "npx npm-run-all --parallel start:frontend-live-demo-app start:backend-live-demo-app" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "npm-run-all": "^4.1.5" + } +}