diff --git a/examples/with-account-linking/README.md b/examples/with-account-linking/README.md index 5a477f2e7..cd4ab4501 100644 --- a/examples/with-account-linking/README.md +++ b/examples/with-account-linking/README.md @@ -16,7 +16,7 @@ Clone the repo, enter the directory, and use `npm` to install the project depend ```bash git clone https://github.com/supertokens/supertokens-auth-react -cd supertokens-auth-react/examples/with-thirdparty-google-onetap +cd supertokens-auth-react/examples/with-account-linking npm install cd frontend && npm install && cd ../ cd backend && npm install && cd ../ @@ -34,17 +34,27 @@ The app will start on `http://localhost:3000` ## How it works -TODO +We are adding a new (`/link`) page where the user can add new login methods to their current user, plus enabling automatic account linking. ### On the frontend The demo uses the pre-built UI, but you can always build your own UI instead. -TODO +- We do not need any extra configuration to enable account linking +- To enable manual linking through a custom callback page, we add `getRedirectURL` to the configuration of the social login providers. +- We add a custom page (`/link`) that will: + - Get and show the login methods belonging to the current user + - Show a password form (if available) that calls `/addPassword` to add an email+password login method to the current user. + - Show a phone number form (if available) that calls `/addPhoneNumber` to associate a phone number with the current user. + - Show an "Add Google account" that start a login process through Google +- We add a custom page (`/link/tpcallback/:thirdPartyId`) that will: + - Call `/addThirdPartyUser` through a customized `ThirdPartyEmailPassword.thirdPartySignInAndUp` call ### On the backend -TODO +- We enable account linking by initializing the recipe and providing a `shouldDoAutomaticAccountLinking` implementation +- We add `/addPassword`, `/addPhoneNumber` and `/addThirdPartyUser` to enable manual linking from the frontend +- We add `/userInfo` so the frontend can list/show the login methods belonging to the current user. ## Author diff --git a/examples/with-account-linking/backend/config.ts b/examples/with-account-linking/backend/config.ts index c83ebc0d3..4fce9944d 100644 --- a/examples/with-account-linking/backend/config.ts +++ b/examples/with-account-linking/backend/config.ts @@ -36,7 +36,6 @@ export const SuperTokensConfig: TypeInput = { EmailVerification.init({ mode: "REQUIRED", }), - UserMetadata.init(), AccountLinking.init({ shouldDoAutomaticAccountLinking: async (newAccountInfo, user, _tenantId, context) => { if (context.doNotLink === true) { @@ -50,6 +49,8 @@ export const SuperTokensConfig: TypeInput = { let hasInfoAssociatedWithUserId = false; // TODO: add your own implementation here. if (hasInfoAssociatedWithUserId) { return { + // Alternatively, you can link users but then you should provide an `onAccountLinked` callback + // that implements merging the user of the two users. shouldAutomaticallyLink: false, }; } diff --git a/examples/with-account-linking/backend/index.ts b/examples/with-account-linking/backend/index.ts index e61c55ae3..13b0cdf0e 100644 --- a/examples/with-account-linking/backend/index.ts +++ b/examples/with-account-linking/backend/index.ts @@ -51,51 +51,55 @@ app.get("/userInfo", verifySession(), async (req: SessionRequest, res) => { app.post("/addPassword", verifySession(), async (req: SessionRequest, res) => { const session = req.session!; + // First we check that the current session (and the user it belongs to) can have a user linked to it. 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( + + const currentLoginMethod = user.loginMethods.find( (m) => m.recipeUserId.getAsString() === session.getRecipeUserId().getAsString() ); - if (!loginMethod) { + if (!currentLoginMethod) { throw new Error("This should never happen"); } - if (loginMethod.recipeId === "emailpassword") { + if (currentLoginMethod.recipeId === "emailpassword") { return res.json({ status: "GENERAL_ERROR", - message: "This user already has a password associated to it", + message: "This user already logged in using a password", }); } - if (!loginMethod.verified) { + if (!currentLoginMethod.verified) { return res.json({ status: "GENERAL_ERROR", message: "You can only add a password when logged in using a verified account", }); } - if (loginMethod.email === undefined) { + if (currentLoginMethod.email === undefined) { return res.json({ status: "GENERAL_ERROR", message: "You can only add a password to accounts associated with email addresses", }); } + // We then "add the password" by signing up with a new user let password: string = req.body.password; - const signUpResp = await ThirdPartyEmailPassword.emailPasswordSignUp( session.getTenantId(), - loginMethod.email, + currentLoginMethod.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. + // In this case the current user has an email that has already signed up + // If they are not linked, the other user can be deleted on the dashboard + // (or merged here if you provide an app specific implementation, but that is a longer/separate topic) return res.json({ status: "GENERAL_ERROR", - message: "This user has already signed up. Please delete it first.", + message: "This user has already signed up using a password.", }); } @@ -107,7 +111,7 @@ app.post("/addPassword", verifySession(), async (req: SessionRequest, res) => { if (linkResp.status !== "OK") { return res.json({ status: "GENERAL_ERROR", - message: linkResp.status, // TODO: proper string + message: `Account linking failed (${linkResp.status})`, }); } // if the access token payload contains any information that'd change based on the new account, we'd want to update it here. @@ -122,6 +126,7 @@ app.post("/addThirdPartyUser", verifySession(), async (req: SessionRequest, res) // We need this because several functions below require it const userContext = {}; const session = req.session!; + // First we check that the current session (and the user it belongs to) can have a user linked to it. const user = await getUser(session.getRecipeUserId().getAsString()); if (!user) { throw new Session.Error({ type: Session.Error.UNAUTHORISED, message: "user removed" }); @@ -140,6 +145,7 @@ app.post("/addThirdPartyUser", verifySession(), async (req: SessionRequest, res) }); } + // We then get get the user info from the third party provider const provider = await ThirdPartyEmailPassword.thirdPartyGetProvider( session.getTenantId(), req.body.thirdPartyId, @@ -171,12 +177,15 @@ app.post("/addThirdPartyUser", verifySession(), async (req: SessionRequest, res) }); } + // In general, we require email verification before linking, but we can skip it in case if the user + // already verified this address in some other way 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", }); } + // We create the new user here const signUpResp = await ThirdPartyEmailPassword.thirdPartyManuallyCreateOrUpdateUser( session.getTenantId(), req.body.thirdPartyId, @@ -191,12 +200,15 @@ app.post("/addThirdPartyUser", verifySession(), async (req: SessionRequest, res) } if (!signUpResp.createdNewRecipeUser) { + // In this case the third party user we tried to add has already signed up + // The other user can be deleted on the dashboard + // (or merged here if you provide an app specific implementation, but that is a longer/separate topic) 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 + // Now 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; @@ -204,7 +216,7 @@ app.post("/addThirdPartyUser", verifySession(), async (req: SessionRequest, res) if (linkResp.status !== "OK") { return res.json({ status: "GENERAL_ERROR", - message: linkResp.status, // TODO: proper string + message: `Account linking failed (${linkResp.status})`, }); } // if the access token payload contains any information that'd change based on the new account, we'd want to update it here. @@ -217,6 +229,7 @@ app.post("/addThirdPartyUser", verifySession(), async (req: SessionRequest, res) app.post("/addPhoneNumber", verifySession(), async (req: SessionRequest, res) => { const session = req.session!; + // First we check that the current session (and the user it belongs to) can have a user linked to it. const user = await getUser(session.getRecipeUserId().getAsString()); if (!user) { throw new Session.Error({ type: Session.Error.UNAUTHORISED, message: "user removed" }); @@ -236,15 +249,6 @@ app.post("/addPhoneNumber", verifySession(), async (req: SessionRequest, res) => } 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, @@ -252,7 +256,6 @@ app.post("/addPhoneNumber", verifySession(), async (req: SessionRequest, res) => }); 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", @@ -264,7 +267,7 @@ app.post("/addPhoneNumber", verifySession(), async (req: SessionRequest, res) => if (linkResp.status !== "OK") { return res.json({ status: "GENERAL_ERROR", - message: linkResp.status, // TODO: proper string + message: `Account linking failed (${linkResp.status})`, }); } // if the access token payload contains any information that'd change based on the new account, we'd want to update it here. diff --git a/examples/with-account-linking/frontend/src/LinkingCallbackPage/index.tsx b/examples/with-account-linking/frontend/src/LinkingCallbackPage/index.tsx index e558352e1..581ea6fc3 100644 --- a/examples/with-account-linking/frontend/src/LinkingCallbackPage/index.tsx +++ b/examples/with-account-linking/frontend/src/LinkingCallbackPage/index.tsx @@ -20,7 +20,7 @@ export const LinkingCallbackPage: React.FC = () => { }), (resp) => { if (resp.status === "OK") { - navigate(`/link?success=${encodeURIComponent("Successfully added account")}`); + navigate(`/link?success=${encodeURIComponent("Successfully added third-party account")}`); } else if ("reason" in resp) { navigate(`/link?error=${encodeURIComponent(resp.reason)}`); } else { diff --git a/examples/with-account-linking/frontend/src/LinkingPage/index.tsx b/examples/with-account-linking/frontend/src/LinkingPage/index.tsx index 5225f85fe..90d47a34b 100644 --- a/examples/with-account-linking/frontend/src/LinkingPage/index.tsx +++ b/examples/with-account-linking/frontend/src/LinkingPage/index.tsx @@ -23,6 +23,47 @@ export const LinkingPage: React.FC = () => { setUserInfo(await res.json()); }, [setUserInfo]); + const addPassword = useCallback(async () => { + const resp = await fetch(`${getApiDomain()}/addPassword`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + password, + }), + }); + + const respBody = await resp.json(); + if (respBody.status !== "OK") { + setSuccess(null); + setError(respBody.reason ?? respBody.message ?? respBody.status); + } else { + setSuccess("Successfully added password"); + setError(null); + } + }, [setError, setSuccess]); + + const addPhoneNumber = useCallback(async () => { + const resp = await fetch(`${getApiDomain()}/addPhoneNumber`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + phoneNumber, + }), + }); + + const respBody = await resp.json(); + if (respBody.status !== "OK") { + setError(respBody.reason ?? respBody.message ?? respBody.status); + } else { + setSuccess("Successfully added password"); + } + loadUserInfo(); + }, [setError, setSuccess, loadUserInfo]); + useEffect(() => { loadUserInfo(); }, [loadUserInfo]); @@ -73,23 +114,7 @@ export const LinkingPage: React.FC = () => { {passwordLoginMethods?.length === 0 && (