Changes to the emailpassword flow
Our approach will be to replace the email
field in SuperTokens with the username field. This will enable username and password login as well as enforce username uniqueness.
Then we will save the optional email against the userID of the user and use that during sign in and reset password flows. The mapping of email to userID will need to be handled by you and stored in your own database. We will create place holder functions in the code snippets below for you to implement them.
#
Modifying the default email validator functionWhen the sign up / in API is called on the backend, SuperTokens first verifies the syntax of the input email. Since we want to replace emails with usernames, we need to change that function to allow for any string
. We can do this in the following way:
- NodeJS
- GoLang
- Python
import SuperTokens from "supertokens-node";import EmailPassword from "supertokens-node/recipe/emailpassword";
SuperTokens.init({ appInfo: { apiDomain: "...", appName: "...", websiteDomain: "..." }, supertokens: { connectionURI: "...", }, recipeList: [ EmailPassword.init({ signUpFeature: { formFields: [{ id: "email", validate: async (value) => { if (typeof value !== "string") { return "Please provide a string input." }
// first we check for if it's an email if ( value.match( /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ ) !== null ) { return undefined; }
// since it's not an email, we check for if it's a correct username if (value.length < 3) { return "Usernames must be at least 3 characters long." } if (!value.match(/^[a-z0-9_-]+$/)) { return "Username must contain only alphanumeric, underscore or hyphen characters." } } }] } }) ]});
note
Work in progress
note
Work in progress
- The
validate
function above is called during sign up, sign in and reset password APIs. In the sign up API, the input would always be a username, but during sign in and reset password APIs, the input could be a username or an email. Therefore, this function needs to check for either of the two formats. - The new
validate
function first checks if the input is a valid email, and if it is, returns early. Else it checks if the input username has at least three characters and contains only alphanumeric, underscore or hyphen characters. If this criteria doesn't match, then the validator returns an appropriate error string. You can modify this function to be more complex and match your criteria. - You may have noticed that the
id
is still"email"
. This is because from SuperTokens' point of view, we will still be storing the username in the SuperTokens' user's email field. This has no side effect other than you (the developer) having to fetch the user's username using theuser.email
field (whereuser
is the user object returned by SuperTokens).
#
Overriding the sign up API to save the user's emailThe sign up API will take in the username, password and an optional email. In order to support this, we must add a new form field for the email and add a validate
function which checks the uniqueness and syntax of the input email.
Then we must override the sign up API to save this email against the userID of that user.
- NodeJS
- GoLang
- Python
import SuperTokens from "supertokens-node";import EmailPassword from "supertokens-node/recipe/emailpassword";
let emailUserMap: {[key: string]: string} = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> { // TODO: Check your database for if the email is associated with a user // and return that user ID if it is.
// this is just a placeholder implementation return emailUserMap[email];}
SuperTokens.init({ appInfo: { apiDomain: "...", appName: "...", websiteDomain: "..." }, supertokens: { connectionURI: "...", }, recipeList: [ EmailPassword.init({ signUpFeature: { formFields: [{ id: "email", validate: async (value) => { // ...from previous code snippet... return undefined } }, { id: "actualEmail", validate: async (value) => { if (value === "") { // this means that the user did not provide an email return undefined; } if ( value.match( /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ ) === null ) { return "Email is invalid"; }
if ((await getUserUsingEmail(value)) !== undefined) { return "Email already in use. Please sign in, or use another email" } } }] } }) ]});
note
Work in progress
note
Work in progress
- We call the new form field
actualEmail
. You can change this if you like, but whatever you set it to, should be used by the frontend as well when calling the sign up API. - You need to implement the
getUserUsingEmail
function to check your database for if there already exists a user with that email. SuperTokens will not have this information since it will be storing the username of the user instead of their email.
Now we must override the sign up API to save the actualEmail
form field value against the user ID.
- NodeJS
- GoLang
- Python
import SuperTokens from "supertokens-node";import EmailPassword from "supertokens-node/recipe/emailpassword";
let emailUserMap: {[key: string]: string} = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> { // TODO: Check your database for if the email is associated with a user // and return that user ID if it is. // this is just a placeholder implementation return emailUserMap[email];}
async function saveEmailForUser(email: string, userId: string) { // TODO: Save email and userId mapping
// this is just a placeholder implementation emailUserMap[email] = userId}
SuperTokens.init({ appInfo: { apiDomain: "...", appName: "...", websiteDomain: "..." }, supertokens: { connectionURI: "...", }, recipeList: [ EmailPassword.init({ override: { apis: (original) => { return { ...original, signUpPOST: async function (input) { let response = await original.signUpPOST!(input); if (response.status === "OK") { // sign up successful let actualEmail = input.formFields.find(i => i.id === "actualEmail")!.value; if (actualEmail === "") { // User did not provide an email. // This is possible since we set optional: true // in the formField config } else { await saveEmailForUser(actualEmail, response.user.id) } } return response } } } }, signUpFeature: { formFields: [ /* ... from previous code snippet ... */] } }) ]});
note
Work in progress
note
Work in progress
- We first call the
original.signUpPOST
function which will sign up the user with username and password login. It will also enforce uniqueness of the username - if not unique, it will return an appropriate reply to the frontend. - If the sign up was successful, we will exptract the
"actualEmail"
form field from the input. If the value is""
, it means that the user did not specify their email. Else we will save the userId and email mapping using thesaveEmailForUser
function. saveEmailForUser
is a function that you must implement, just like how you had implemented thegetUserUsingEmail
function.
#
Overriding the sign in backend function to accept email or usernameWe want the user to be able to sign in using their email or username along with their password. In order to implement this, we must override the sign in recipe function as follows:
- NodeJS
- GoLang
- Python
import SuperTokens from "supertokens-node";import EmailPassword from "supertokens-node/recipe/emailpassword";
let emailUserMap: {[key: string]: string} = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> { // TODO: Check your database for if the email is associated with a user // and return that user ID if it is. // this is just a placeholder implementation return emailUserMap[email];}
async function saveEmailForUser(email: string, userId: string) { // TODO: Save email and userId mapping
// this is just a placeholder implementation emailUserMap[email] = userId}
function isInputEmail(input: string): boolean { return input.match( /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ ) !== null;}
SuperTokens.init({ appInfo: { apiDomain: "...", appName: "...", websiteDomain: "..." }, supertokens: { connectionURI: "...", }, recipeList: [ EmailPassword.init({ override: { functions: (original) => { return { ...original, signIn: async function (input) { if (isInputEmail(input.email)) { let userId = await getUserUsingEmail(input.email); if (userId !== undefined) { let superTokensUser = await EmailPassword.getUserById(userId); if (superTokensUser !== undefined) { input.email = superTokensUser.email } } } return original.signIn(input); } } }, apis: (original) => { return { ...original, // override from previous code snippet } } }, signUpFeature: { formFields: [ /* ... from previous code snippet ... */] } }) ]});
note
Work in progress
note
Work in progress
- Notice that this time, we provided the override function to
override.function
config and not theoverride.apis
config. This is because this function is called not only duringt the sign in API, but also if you call theEmailpassword.signIn
function manually in your own APIs. - First we check if the input is a valid email - using a regex. If it's not, then we call the
original.signIn
function which will try to do a username + password login. - If the input is an email, then we fetch the userID of that email using the previously implemented
getUserUsingEmail
function. If that cannot find a user mapping, then we let the code fallthrough tooriginal.signIn
. - If a user ID was found, then we query SuperTokens using
EmailPassword.getUserById
to get the SuperTokens user object. Freom there, we change theinput.email
to the username of the user, which is stored insuperTokensUser.email
. After modifying the input, we let theoriginal.signIn
function run which will attempt a username and password login.
#
Overriding the password reset link API to accept username or emailThe password reset flow requires the user to have added an email during sign up. If there is no email associated with the user, we return an appropriate message to them to contact support.
We allow the user to enter either their username or their email when starting the password reset flow. Just like the sign in cusomtisation, we must check if the input is an email, and if it is, fetch the username associated with the user before calling the SuperTokens' default implementation of the generate password reset link API.
- NodeJS
- GoLang
- Python
import SuperTokens from "supertokens-node";import EmailPassword from "supertokens-node/recipe/emailpassword";
let emailUserMap: {[key: string]: string} = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> { // TODO: Check your database for if the email is associated with a user // and return that user ID if it is. // this is just a placeholder implementation return emailUserMap[email];}
async function saveEmailForUser(email: string, userId: string) { // TODO: Save email and userId mapping
// this is just a placeholder implementation emailUserMap[email] = userId}
function isInputEmail(input: string): boolean { return input.match( /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ ) !== null;}
async function getEmailUsingUserId(userId: string) { // TODO: check your database mapping..
// this is just a placeholder implementation let emails = Object.keys(emailUserMap) for (let i = 0; i < emails.length; i++) { if (emailUserMap[emails[i]] === userId) { return emails[i] } } return undefined;}
SuperTokens.init({ appInfo: { apiDomain: "...", appName: "...", websiteDomain: "..." }, supertokens: { connectionURI: "...", }, recipeList: [ EmailPassword.init({ override: { functions: (original) => { return { ...original, // ...override from previous code snippet... } }, apis: (original) => { return { ...original, // ...override from previous code snippet... generatePasswordResetTokenPOST: async function (input) { let emailOrUsername = input.formFields.find(i => i.id === "email")!.value; if (isInputEmail(emailOrUsername)) { let userId = await getUserUsingEmail(emailOrUsername); if (userId !== undefined) { let superTokensUser = await EmailPassword.getUserById(userId); if (superTokensUser !== undefined) { // we replace the input form field's array item // to contain the username instead of the email. input.formFields = input.formFields.filter(i => i.id !== "email") input.formFields = [...input.formFields, { id: "email", value: superTokensUser.email }] } } }
let username = input.formFields.find(i => i.id === "email")!.value; let superTokensUser = await EmailPassword.getUserByEmail(username); if (superTokensUser !== undefined) { if ((await getEmailUsingUserId(superTokensUser.id)) === undefined) { return { status: "GENERAL_ERROR", message: "You need to add an email to your account for resetting your password. Please contact support." } } } return original.generatePasswordResetTokenPOST!(input); }, } } }, signUpFeature: { formFields: [ /* ... from previous code snippet ... */] } }) ]});
note
Work in progress
note
Work in progress
- If the user has entered their email ID, then we first try and replace it with their username - just like how we did it in the sign in function.
- Once we have the username, or if the user had entered their username originally, we try and check if there exists an email for that username. To do this, we call the
getUserByEmail
function, passing in the username of the user. This will query SuperTokens to get the user object containing the user ID. Once we have the userID, we can see if there exists an email for that userId by calling thegetEmailUsingUserId
function. getEmailUsingUserId
is a function which you must implement by querying your database.- If an email is not returned, it means that the user had signed up without an email. So we send an appropriate message to the frontend. Else we call the
original.generatePasswordResetTokenPOST
function.
The above cusomtisation will allow users to enter an email or a username in the password reset form. However, when the SDK is sending an email, it will try and send it to the username. That of course, won't work. Therefore, we must override the sendEmail
function as well to fetch the email of the user from the input user ID before calling the original.sendEmail
- NodeJS
- GoLang
- Python
import SuperTokens from "supertokens-node";import EmailPassword from "supertokens-node/recipe/emailpassword";
let emailUserMap: {[key: string]: string} = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> { // TODO: Check your database for if the email is associated with a user // and return that user ID if it is. // this is just a placeholder implementation return emailUserMap[email];}
async function saveEmailForUser(email: string, userId: string) { // TODO: Save email and userId mapping
// this is just a placeholder implementation emailUserMap[email] = userId}
function isInputEmail(input: string): boolean { return input.match( /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ ) !== null;}
async function getEmailUsingUserId(userId: string) { // TODO: check your database mapping..
// this is just a placeholder implementation let emails = Object.keys(emailUserMap) for (let i = 0; i < emails.length; i++) { if (emailUserMap[emails[i]] === userId) { return emails[i] } } return undefined;}
SuperTokens.init({ appInfo: { apiDomain: "...", appName: "...", websiteDomain: "..." }, supertokens: { connectionURI: "...", }, recipeList: [ EmailPassword.init({ override: { /* ...from previous code snippets... */ }, signUpFeature: { formFields: [ /* ... from previous code snippet ... */] }, emailDelivery: { override: (original) => { return { ...original, sendEmail: async function (input) { input.user.email = (await getEmailUsingUserId(input.user.id))!; return original.sendEmail(input) } } } }, }) ]});
note
Work in progress
note
Work in progress
- In the above code snippet, we override the
sendEmail
function which is called by the SDK to send the reset password email. - The input email to the function is actually the user's username (since SuperTokens stores the username and not the email). We simply fetch the user's actual email from the
getEmailUsingUserId
function which we implemented previously and then assign that to the input before calling the original implementation. - Note that this function will only be called if the user has an email associated with their account since we check for that in the override for
generatePasswordResetTokenPOST
.
note
This completes the changes required on the backend. Below are changes required for the frontend SDK.
We need to make the following customizations to the frontend's default UI:
- Change the email validator to not do validation on the frontend since we allow username or email. The backend will do appropriate checks anyway.
- Change the "Email" label in the sign up form to say "Username".
- Add an extra field in the sign up form where the user can enter their email.
- Change the "Email" label in the sign in form to say "Username or email".
- Change the "Email" label in the password reset form to say "Username or email".
These can be made using the following configs in the init
function:
import SuperTokens from "supertokens-auth-react"import EmailPassword from "supertokens-auth-react/recipe/emailpassword"
SuperTokens.init({ appInfo: { appName: "...", apiDomain: "...", websiteDomain: "...", }, languageTranslations: { translations: { "en": { EMAIL_PASSWORD_EMAIL_LABEL: "Username or email" } } }, recipeList: [ EmailPassword.init({ signInAndUpFeature: { signInForm: { formFields: [{ id: "email", label: "Username or email", placeholder: "Username or email" }] }, signUpForm: { formFields: [{ id: "email", label: "Username", placeholder: "Username", validate: async (input) => { // the backend validates this anyway. So nothing required here return undefined; } }, { id: "actualEmail", validate: async (input) => { // the backend validates this anyway. So nothing required here return undefined }, label: "Email", optional: true }] } } }), // other recipes initialisation.. ],});
- The
languageTranslations
config will replace the label for "Email" in the password reset form. - We add the
signInForm
config and change the label and the placeholder associated with the"email"
input. - We add the
signUpForm
config in which we:- Change the label and the placeholder associated with the
"email"
input. The originalemail
field will correspond to the username now. - Add an additional (optiona) form field in which the user can enter their email. This would correspond to the
"actualEmail"
form field on the backend.
- Change the label and the placeholder associated with the
success
This completes the changes required.