Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/user/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prefabs.tech/fastify-user",
"version": "0.93.5",
"version": "0.94.0-beta.1",
"description": "Fastify user plugin",
"homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/user#readme",
"repository": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { CustomError } from "@prefabs.tech/fastify-error-handler";
import { formatDate } from "@prefabs.tech/fastify-slonik";
import { deleteUser, getRequestFromUserContext } from "supertokens-node";
import UserRoles from "supertokens-node/recipe/userroles";

import { User, UserCreateInput } from "src/types";

import { ROLE_USER } from "../../../../constants";
import getUserService from "../../../../lib/getUserService";
import areRolesExist from "../../../utils/areRolesExist";

import type { FastifyInstance, FastifyRequest } from "fastify";
import type { RecipeInterface } from "supertokens-node/recipe/passwordless/types";

const consumeCode = (
originalImplementation: RecipeInterface,
fastify: FastifyInstance,
): RecipeInterface["consumeCode"] => {
return async (input) => {
const roles = (input.userContext.roles || [
fastify.config.user.role || ROLE_USER,
]) as string[];

if (!(await areRolesExist(roles))) {
throw new CustomError(
`At least one role from ${roles.join(", ")} does not exist.`,
"SIGNUP_FAILED_ERROR",
);
}

const originalResponse = await originalImplementation.consumeCode(input);

if (originalResponse.status !== "OK") {
return originalResponse;
}

const request = getRequestFromUserContext(input.userContext)?.original as
| FastifyRequest
| undefined;

const userService = getUserService(
request?.config || fastify.config,
request?.slonik || fastify.slonik,
request?.dbSchema,
);

const phoneNumber = originalResponse.user.phoneNumber;

const emailDomain =
fastify.config.user.fallbackEmailDomain ||
fastify.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com";

const email = phoneNumber
? `${phoneNumber}@${emailDomain}`
: originalResponse.user.email;

if (!email || !phoneNumber) {
await deleteUser(originalResponse.user.id);

throw new Error("Passwordless user missing phoneNumber or email");
}

let user: User | null | undefined;

if (originalResponse.createdNewUser) {
try {
user = await userService.create({
id: originalResponse.user.id,
email,
phoneNumber,
} as UserCreateInput);

if (!user) {
throw new Error("User not found");
}
} catch (error) {
await deleteUser(originalResponse.user.id);

throw error;
}

user.roles = roles;

originalResponse.user = {
...originalResponse.user,
...user,
};

for (const role of roles) {
const rolesResponse = await UserRoles.addRoleToUser(
originalResponse.user.id,
role,
);

if (rolesResponse.status !== "OK") {
fastify.log.error(rolesResponse.status);
}
}
} else {
await userService
.update(originalResponse.user.id, {
lastLoginAt: formatDate(new Date(Date.now())),
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.catch((error: any) => {
fastify.log.error(
`Unable to update lastLoginAt for userId ${originalResponse.user.id}`,
);
fastify.log.error(error);
});
}

return {
...originalResponse,
user: {
...originalResponse.user,
email,
phoneNumber,
},
};
};
};

export default consumeCode;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ROLE_USER } from "../../../../constants";

import type { FastifyInstance } from "fastify";
import type { APIInterface } from "supertokens-node/recipe/passwordless/types";

const consumeCodePOST = (
originalImplementation: APIInterface,
fastify: FastifyInstance,
): APIInterface["consumeCodePOST"] => {
return async (input) => {
input.userContext.roles = input.userContext.roles || [
fastify.config.user.role || ROLE_USER,
];

if (originalImplementation.consumeCodePOST === undefined) {
throw new Error("Should never come here");
}

return originalImplementation.consumeCodePOST(input);
};
};

export default consumeCodePOST;
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { FastifyInstance } from "fastify";
import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery";

import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe";

import consumeCode from "./passwordless/consumeCode";
import consumeCodePOST from "./passwordless/consumeCodePost";

import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio";
import type {
APIInterface,
RecipeInterface,
TypeInput as PasswordlessRecipeConfig,
} from "supertokens-node/recipe/passwordless/types";

const getPasswordlessRecipeConfig = (
fastify: FastifyInstance,
): PasswordlessRecipeConfig => {
const { config } = fastify;
const isDevelopment = process.env.NODE_ENV === "development";
const defaultTestOtp = process.env.DEFAULT_TEST_OTP || "123456";

let passwordless: PasswordlessRecipe = {};

if (typeof config.user.supertokens.recipes?.passwordless === "object") {
passwordless = config.user.supertokens.recipes.passwordless;
}

let twilioSettings: TwilioServiceConfig | undefined;

if (!isDevelopment) {
if (!config.user.twilio) {
throw new Error(
"Twilio config is missing for passwordless recipe. Please add twilio config to your app config.",
);
}

if (
!("messagingServiceSid" in config.user.twilio) &&
!("from" in config.user.twilio)
) {
throw new Error(
"Twilio config requires either messagingServiceSid or from",
);
}

twilioSettings =
"messagingServiceSid" in config.user.twilio
? {
opts: config.user.twilio.opts,
accountSid: config.user.twilio.accountSid,
authToken: config.user.twilio.authToken,
messagingServiceSid: config.user.twilio.messagingServiceSid,
}
: {
opts: config.user.twilio.opts,
accountSid: config.user.twilio.accountSid,
authToken: config.user.twilio.authToken,
from: config.user.twilio.from,
};
}

return {
contactMethod: passwordless?.contactMethod || "PHONE",
flowType: passwordless?.flowType || "USER_INPUT_CODE",
...(isDevelopment
? {
getCustomUserInputCode: () => defaultTestOtp,
}
: {}),
override: {
apis: (originalImplementation) => {
const apiInterface: Partial<APIInterface> = {};

if (passwordless.override?.apis) {
const apis = passwordless.override.apis;

let api: keyof APIInterface;

for (api in apis) {
const apiWrapper = apis[api];

if (apiWrapper) {
apiInterface[api] = apiWrapper(
originalImplementation,
fastify,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
}
}
}

return {
...originalImplementation,
consumeCodePOST: consumeCodePOST(originalImplementation, fastify),
...apiInterface,
};
},
functions: (originalImplementation) => {
const recipeInterface: Partial<RecipeInterface> = {};

if (passwordless.override?.functions) {
const recipes = passwordless.override.functions;

let recipe: keyof RecipeInterface;

for (recipe in recipes) {
const recipeWrapper = recipes[recipe];

if (recipeWrapper) {
recipeInterface[recipe] = recipeWrapper(
originalImplementation,
fastify,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
}
}
}

return {
...originalImplementation,
consumeCode: consumeCode(originalImplementation, fastify),
...recipeInterface,
};
},
},
...(isDevelopment
? {
createAndSendCustomTextMessage: async () => {
fastify.log.info(
`Skipping passwordless SMS delivery in development environment. Use default OTP [${defaultTestOtp}] for testing.`,
);
},
}
: {
smsDelivery: {
service: new TwilioService({
twilioSettings: twilioSettings as TwilioServiceConfig,
override: (originalImplementation) => {
return {
...originalImplementation,
getContent: async (input) => {
const message =
config.user.twilio?.message ||
"Your verification code is:";

return {
body: `${message} ${input.userInputCode}.`,
toPhoneNumber: input.phoneNumber,
};
},
sendRawSms: async (input) => {
await originalImplementation.sendRawSms(input);
},
};
},
}),
},
}),
};
};

export default getPasswordlessRecipeConfig;
2 changes: 2 additions & 0 deletions packages/user/src/supertokens/recipes/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import initEmailVerificationRecipe from "./initEmailVerificationRecipe";
import initPasswordlessRecipe from "./initPasswordlessRecipe";
import initSessionRecipe from "./initSessionRecipe";
import initThirdPartyEmailPassword from "./initThirdPartyEmailPasswordRecipe";
import initUserRolesRecipe from "./initUserRolesRecipe";
Expand All @@ -8,6 +9,7 @@ import type { RecipeListFunction } from "supertokens-node/types";

const getRecipeList = (fastify: FastifyInstance): RecipeListFunction[] => {
const recipeList = [
initPasswordlessRecipe(fastify),
initSessionRecipe(fastify),
initThirdPartyEmailPassword(fastify),
initUserRolesRecipe(fastify),
Expand Down
19 changes: 19 additions & 0 deletions packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { FastifyInstance } from "fastify";
import Passwordless from "supertokens-node/recipe/passwordless";

import getPasswordlessRecipeConfig from "./config/passwordlessRecipeConfig";

import type { SupertokensRecipes } from "../types";

const init = (fastify: FastifyInstance) => {
const passwordless: SupertokensRecipes["passwordless"] =
fastify.config.user.supertokens.recipes?.passwordless;

if (typeof passwordless === "function") {
return Passwordless.init(passwordless(fastify));
}

return Passwordless.init(getPasswordlessRecipeConfig(fastify));
};

export default init;
5 changes: 5 additions & 0 deletions packages/user/src/supertokens/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
} from "supertokens-node/recipe/thirdpartyemailpassword";

import type { EmailVerificationRecipe } from "./emailVerificationRecipe";
import type { PasswordlessRecipe } from "./passwordlessRecipe";
import type { SessionRecipe } from "./sessionRecipe";
import type { ThirdPartyEmailPasswordRecipe } from "./thirdPartyEmailPasswordRecipe";
import type { FastifyInstance } from "fastify";
import type { TypeInput as EmailVerificationRecipeConfig } from "supertokens-node/recipe/emailverification/types";
import type { TypeInput as PasswordlessRecipeConfig } from "supertokens-node/recipe/passwordless/types";
import type { TypeInput as SessionRecipeConfig } from "supertokens-node/recipe/session/types";
import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword";
import type { TypeInput as ThirdPartyEmailPasswordRecipeConfig } from "supertokens-node/recipe/thirdpartyemailpassword/types";
Expand All @@ -19,6 +21,9 @@ interface SupertokensRecipes {
emailVerification?:
| EmailVerificationRecipe
| ((fastify: FastifyInstance) => EmailVerificationRecipeConfig);
passwordless?:
| PasswordlessRecipe
| ((fastify: FastifyInstance) => PasswordlessRecipeConfig);
session?: SessionRecipe | ((fastify: FastifyInstance) => SessionRecipeConfig);
userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig;
thirdPartyEmailPassword?:
Expand Down
Loading
Loading