Skip to content

Commit

Permalink
docs(examples): with-account-linking docs/small simplifications
Browse files Browse the repository at this point in the history
  • Loading branch information
porcellus committed Sep 6, 2023
1 parent 9072d3c commit ee794ff
Show file tree
Hide file tree
Showing 7 changed files with 87 additions and 76 deletions.
18 changes: 14 additions & 4 deletions examples/with-account-linking/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ../
Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion examples/with-account-linking/backend/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
};
}
Expand Down
51 changes: 27 additions & 24 deletions examples/with-account-linking/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
});
}

Expand All @@ -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.
Expand All @@ -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" });
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -191,20 +200,23 @@ 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;

const linkResp = await AccountLinking.linkAccounts(newRecipeUserId, session.getUserId());
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.
Expand All @@ -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" });
Expand All @@ -236,23 +249,13 @@ 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,
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",
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
80 changes: 43 additions & 37 deletions examples/with-account-linking/frontend/src/LinkingPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -73,23 +114,7 @@ export const LinkingPage: React.FC = () => {
{passwordLoginMethods?.length === 0 && (
<form
onSubmit={(ev) => {
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");
}
});
});
addPassword();
ev.preventDefault();
return false;
}}>
Expand All @@ -100,26 +125,7 @@ export const LinkingPage: React.FC = () => {
{phoneLoginMethod?.length === 0 && (
<form
onSubmit={(ev) => {
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("");
});
addPhoneNumber();
ev.preventDefault();
return false;
}}>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit ee794ff

Please sign in to comment.