From 1f168cec226a72c407be5b0c67f09d0b4126aad1 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 2 Oct 2025 19:18:37 +0300 Subject: [PATCH 01/11] fix tag component type --- shared/ui/src/components/tag/tag.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shared/ui/src/components/tag/tag.tsx b/shared/ui/src/components/tag/tag.tsx index a20e6dc..60f5ea5 100644 --- a/shared/ui/src/components/tag/tag.tsx +++ b/shared/ui/src/components/tag/tag.tsx @@ -6,11 +6,11 @@ import { HTMLElementProps, BaseWaProps } from "../types"; const cx = classNames.bind(styles); export interface TagProps extends HTMLElementProps { - variant: "brand" | "neutral" | "success" | "warning" | "danger"; + variant?: "brand" | "neutral" | "success" | "warning" | "danger"; /** The tag's visual appearance. */ - appearance: "accent" | "outlined accent" | "filled" | "outlined" | "outlined filled"; + appearance?: "accent" | "outlined accent" | "filled" | "outlined" | "outlined filled"; /** The tag's size. */ - size: "xsmall" | "small" | "medium" | "large"; + size?: "xsmall" | "small" | "medium" | "large"; children: React.ReactNode; } From 59dff8a8889193e1bad0f7c5169c8aca8381461c Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 2 Oct 2025 19:18:56 +0300 Subject: [PATCH 02/11] package fixes --- package-lock.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index e291768..9a42d7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14955,7 +14955,7 @@ }, "packages/captcha-nodejs": { "name": "@supertokens-plugins/captcha-nodejs", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "supertokens-node": "^23.0.0" @@ -15792,7 +15792,7 @@ }, "packages/captcha-react": { "name": "@supertokens-plugins/captcha-react", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { "supertokens-auth-react": "^0.50.0" @@ -17469,14 +17469,14 @@ "name": "@supertokens-plugins/profile-base-react", "version": "0.0.1", "dependencies": { - "@shared/js": "*", - "@shared/react": "*", - "@shared/ui": "*", "supertokens-js-override": "^0.0.4" }, "devDependencies": { "@shared/eslint": "*", + "@shared/js": "*", + "@shared/react": "*", "@shared/tsconfig": "*", + "@shared/ui": "*", "@storybook/react": "^9.1.7", "@types/react": "^17.0.20", "prettier": "3.5.3", @@ -17659,15 +17659,15 @@ "name": "@supertokens-plugins/profile-details-react", "version": "0.0.1-beta.1", "dependencies": { - "@shared/js": "*", - "@shared/react": "*", - "@shared/ui": "*", "@supertokens-plugins/profile-details-shared": "*", "supertokens-js-override": "^0.0.4" }, "devDependencies": { "@shared/eslint": "*", + "@shared/js": "*", + "@shared/react": "*", "@shared/tsconfig": "*", + "@shared/ui": "*", "@types/react": "^17.0.20", "prettier": "3.5.3", "pretty-quick": "^4.2.2", @@ -17719,7 +17719,7 @@ }, "packages/progressive-profiling-nodejs": { "name": "@supertokens-plugins/progressive-profiling-nodejs", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { "@shared/eslint": "*", "@shared/nodejs": "*", @@ -17855,7 +17855,7 @@ }, "packages/progressive-profiling-react": { "name": "@supertokens-plugins/progressive-profiling-react", - "version": "0.0.5", + "version": "0.1.2", "dependencies": { "supertokens-js-override": "^0.0.4" }, @@ -18465,7 +18465,7 @@ }, "packages/tenant-discovery-nodejs": { "name": "@supertokens-plugins/tenant-discovery-nodejs", - "version": "0.2.0", + "version": "0.2.1", "devDependencies": { "@shared/eslint": "*", "@shared/nodejs": "*", @@ -19222,7 +19222,7 @@ }, "packages/user-banning-nodejs": { "name": "@supertokens-plugins/user-banning-nodejs", - "version": "0.2.0", + "version": "0.2.1", "devDependencies": { "@shared/eslint": "*", "@shared/nodejs": "*", @@ -19354,7 +19354,7 @@ }, "packages/user-banning-react": { "name": "@supertokens-plugins/user-banning-react", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "supertokens-js-override": "^0.0.4" }, From 766cc4cafc4bc9dbf67ab1e0aa6af07f0a703a86 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 2 Oct 2025 19:19:10 +0300 Subject: [PATCH 03/11] ported plugin --- packages/profile-security-nodejs/.eslintrc.js | 10 + .../profile-security-nodejs/.prettierrc.js | 4 + packages/profile-security-nodejs/package.json | 42 ++ .../profile-security-nodejs/src/constants.ts | 7 + packages/profile-security-nodejs/src/index.ts | 4 + .../profile-security-nodejs/src/logger.ts | 4 + .../profile-security-nodejs/src/plugin.ts | 610 ++++++++++++++++++ packages/profile-security-nodejs/src/types.ts | 5 + .../profile-security-nodejs/tsconfig.json | 13 + .../profile-security-nodejs/vite.config.ts | 37 ++ .../profile-security-nodejs/vitest.config.ts | 16 + packages/profile-security-react/.eslintrc.js | 14 + .../profile-security-react/.prettierrc.js | 4 + packages/profile-security-react/CHANGELOG.md | 7 + packages/profile-security-react/README.md | 1 + packages/profile-security-react/package.json | 46 ++ packages/profile-security-react/src/api.ts | 126 ++++ .../src/components/index.ts | 1 + .../change-password-section.tsx | 67 ++ .../src/components/security-section/index.ts | 1 + .../security-section/mfa-factor-email-otp.tsx | 214 ++++++ .../security-section/mfa-factor-phone-otp.tsx | 265 ++++++++ .../security-section/mfa-factor-totp.tsx | 450 +++++++++++++ .../security-section/mfa-section.tsx | 209 ++++++ .../security-section.module.css | 165 +++++ .../security-section/security-section.tsx | 152 +++++ .../security-section/set-password-section.tsx | 95 +++ .../security-section/set-webauthn-section.tsx | 80 +++ .../security-section/third-party-section.tsx | 182 ++++++ .../security-section/webauthn-section.tsx | 186 ++++++ .../profile-security-react/src/constants.ts | 4 + packages/profile-security-react/src/css.d.ts | 29 + packages/profile-security-react/src/index.ts | 5 + packages/profile-security-react/src/logger.ts | 5 + packages/profile-security-react/src/plugin.ts | 123 ++++ .../src/security-section-wrapper.tsx | 12 + .../src/translations.ts | 138 ++++ packages/profile-security-react/src/types.ts | 21 + packages/profile-security-react/tsconfig.json | 13 + .../profile-security-react/vite.config.ts | 43 ++ 40 files changed, 3410 insertions(+) create mode 100644 packages/profile-security-nodejs/.eslintrc.js create mode 100644 packages/profile-security-nodejs/.prettierrc.js create mode 100644 packages/profile-security-nodejs/package.json create mode 100644 packages/profile-security-nodejs/src/constants.ts create mode 100644 packages/profile-security-nodejs/src/index.ts create mode 100644 packages/profile-security-nodejs/src/logger.ts create mode 100644 packages/profile-security-nodejs/src/plugin.ts create mode 100644 packages/profile-security-nodejs/src/types.ts create mode 100644 packages/profile-security-nodejs/tsconfig.json create mode 100644 packages/profile-security-nodejs/vite.config.ts create mode 100644 packages/profile-security-nodejs/vitest.config.ts create mode 100644 packages/profile-security-react/.eslintrc.js create mode 100644 packages/profile-security-react/.prettierrc.js create mode 100644 packages/profile-security-react/CHANGELOG.md create mode 100644 packages/profile-security-react/README.md create mode 100644 packages/profile-security-react/package.json create mode 100644 packages/profile-security-react/src/api.ts create mode 100644 packages/profile-security-react/src/components/index.ts create mode 100644 packages/profile-security-react/src/components/security-section/change-password-section.tsx create mode 100644 packages/profile-security-react/src/components/security-section/index.ts create mode 100644 packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx create mode 100644 packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx create mode 100644 packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx create mode 100644 packages/profile-security-react/src/components/security-section/mfa-section.tsx create mode 100644 packages/profile-security-react/src/components/security-section/security-section.module.css create mode 100644 packages/profile-security-react/src/components/security-section/security-section.tsx create mode 100644 packages/profile-security-react/src/components/security-section/set-password-section.tsx create mode 100644 packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx create mode 100644 packages/profile-security-react/src/components/security-section/third-party-section.tsx create mode 100644 packages/profile-security-react/src/components/security-section/webauthn-section.tsx create mode 100644 packages/profile-security-react/src/constants.ts create mode 100644 packages/profile-security-react/src/css.d.ts create mode 100644 packages/profile-security-react/src/index.ts create mode 100644 packages/profile-security-react/src/logger.ts create mode 100644 packages/profile-security-react/src/plugin.ts create mode 100644 packages/profile-security-react/src/security-section-wrapper.tsx create mode 100644 packages/profile-security-react/src/translations.ts create mode 100644 packages/profile-security-react/src/types.ts create mode 100644 packages/profile-security-react/tsconfig.json create mode 100644 packages/profile-security-react/vite.config.ts diff --git a/packages/profile-security-nodejs/.eslintrc.js b/packages/profile-security-nodejs/.eslintrc.js new file mode 100644 index 0000000..915d347 --- /dev/null +++ b/packages/profile-security-nodejs/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: [require.resolve("@shared/eslint/node.js")], + parserOptions: { + project: "tsconfig.json", + tsconfigRootDir: __dirname, + sourceType: "module", + }, + ignorePatterns: ["**/*.test.ts", "**/*.spec.ts"], +}; diff --git a/packages/profile-security-nodejs/.prettierrc.js b/packages/profile-security-nodejs/.prettierrc.js new file mode 100644 index 0000000..8986fc5 --- /dev/null +++ b/packages/profile-security-nodejs/.prettierrc.js @@ -0,0 +1,4 @@ +/** @type {import("prettier").Config} */ +module.exports = { + ...require("@shared/eslint/prettier"), +}; diff --git a/packages/profile-security-nodejs/package.json b/packages/profile-security-nodejs/package.json new file mode 100644 index 0000000..d5393de --- /dev/null +++ b/packages/profile-security-nodejs/package.json @@ -0,0 +1,42 @@ +{ + "name": "@supertokens-plugins/profile-security-nodejs", + "version": "0.0.2-beta.2", + "description": "Profile Security Plugin for SuperTokens", + "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/profile-security-nodejs/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/supertokens/supertokens-plugins.git", + "directory": "packages/profile-security-nodejs" + }, + "scripts": { + "build": "vite build && npm run pretty", + "pretty": "npx pretty-quick .", + "pretty-check": "npx pretty-quick --check .", + "test": "TEST_MODE=testing vitest run --pool=forks" + }, + "keywords": [ + "progressive-profiling", + "plugin", + "supertokens" + ], + "dependencies": {}, + "peerDependencies": { + "supertokens-node": ">=23.0.0" + }, + "devDependencies": { + "@shared/eslint": "*", + "@shared/tsconfig": "*", + "@shared/nodejs": "*", + "express": "^5.1.0", + "prettier": "2.0.5", + "pretty-quick": "^3.1.1", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "browser": { + "fs": false + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts" +} diff --git a/packages/profile-security-nodejs/src/constants.ts b/packages/profile-security-nodejs/src/constants.ts new file mode 100644 index 0000000..912aa1b --- /dev/null +++ b/packages/profile-security-nodejs/src/constants.ts @@ -0,0 +1,7 @@ +export const PLUGIN_ID = "supertokens-plugin-profile-security"; +export const PLUGIN_VERSION = "0.0.1"; +export const PLUGIN_SDK_VERSION = ["23.0.1", ">=23.0.1"]; + +export const HANDLE_BASE_PATH = `/plugin/${PLUGIN_ID}`; + +export const METADATA_KEY = `${PLUGIN_ID}`; diff --git a/packages/profile-security-nodejs/src/index.ts b/packages/profile-security-nodejs/src/index.ts new file mode 100644 index 0000000..dcb7429 --- /dev/null +++ b/packages/profile-security-nodejs/src/index.ts @@ -0,0 +1,4 @@ +import { init } from "./plugin"; +import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; +export { init, PLUGIN_ID, PLUGIN_VERSION }; +export default { init, PLUGIN_ID, PLUGIN_VERSION }; diff --git a/packages/profile-security-nodejs/src/logger.ts b/packages/profile-security-nodejs/src/logger.ts new file mode 100644 index 0000000..5943019 --- /dev/null +++ b/packages/profile-security-nodejs/src/logger.ts @@ -0,0 +1,4 @@ +import { buildLogger } from "@shared/nodejs"; +import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; + +export const { logDebugMessage, enableDebugLogs } = buildLogger(PLUGIN_ID, PLUGIN_VERSION); diff --git a/packages/profile-security-nodejs/src/plugin.ts b/packages/profile-security-nodejs/src/plugin.ts new file mode 100644 index 0000000..eceeb5a --- /dev/null +++ b/packages/profile-security-nodejs/src/plugin.ts @@ -0,0 +1,610 @@ +import { getUser, isRecipeInitialized, deleteUser, getAvailableFirstFactors } from "supertokens-node"; +import { updateEmailOrPassword, verifyCredentials, signUp } from "supertokens-node/recipe/emailpassword"; +import MultiFactorAuth, { FactorIds } from "supertokens-node/recipe/multifactorauth"; +import AccountLinking from "supertokens-node/recipe/accountlinking"; +import TOTP from "supertokens-node/recipe/totp"; +import Passwordless from "supertokens-node/recipe/passwordless"; +import { SuperTokensPlugin } from "supertokens-node/types"; + +import { withRequestHandler } from "@shared/nodejs"; +import { createPluginInitFunction } from "@shared/js"; + +import { SuperTokensPluginProfileSecurityConfig } from "./types"; +import { PLUGIN_ID, HANDLE_BASE_PATH, PLUGIN_SDK_VERSION } from "./constants"; +import { enableDebugLogs, logDebugMessage } from "./logger"; + +export const init = createPluginInitFunction< + SuperTokensPlugin, + SuperTokensPluginProfileSecurityConfig, + never, + Required +>( + (pluginConfig) => { + return { + id: PLUGIN_ID, + compatibleSDKVersions: PLUGIN_SDK_VERSION, + init: (config) => { + if (config.debug) { + enableDebugLogs(); + } + }, + routeHandlers: () => { + return { + status: "OK", + routeHandlers: [ + { + path: HANDLE_BASE_PATH + "/config", + method: "get", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + return { + status: "OK", + config: { + enableSettingPassword: pluginConfig.enableSettingPassword, + enableThirdPartyLinkning: pluginConfig.enableThirdPartyLinkning, + enableMfaConfiguration: pluginConfig.enableMfaConfiguration, + }, + }; + }), + }, + { + path: HANDLE_BASE_PATH + "/password/set", + method: "post", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + if (!isRecipeInitialized("emailpassword")) { + return { + status: "ERROR", + message: "Changing password requires the EmailPassword recipe to be initialized", + }; + } + + const { newPassword, email } = await req.getJSONBody(); + if (!email || !newPassword) { + return { + status: "ERROR", + message: "Email and password are required", + }; + } + + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + // todo decide if we should allow setting password for other emails + if (!user.emails.includes(email)) { + return { + status: "ERROR", + message: "The user does not have this email address", + }; + } + + const passwordLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "emailpassword"); + if (passwordLoginMethods.length) { + return { + status: "ERROR", + message: + "User already has a password set. Please use the change password feature to update your password.", + }; + } + + // todo firgure out how to handle email verification + const signUpResult = await signUp( + session!.getTenantId(), + email, + newPassword, + session!, // todo: ??? should we pass the session or try linking later? + userContext, + ); + if (signUpResult.status === "EMAIL_ALREADY_EXISTS_ERROR") { + return { + status: "ERROR", + message: + "There already exists a user with this email address. Please use the change password feature to update your password.", + }; + } + if (signUpResult.status === "LINKING_TO_SESSION_USER_FAILED") { + return { + status: "ERROR", + message: "Could not link the new password to the user. Please contact support.", + }; + } + if (signUpResult.status !== "OK") { + return { + status: "ERROR", + message: "Password change failed", + }; + } + + const linkResp = await AccountLinking.linkAccounts(signUpResult.recipeUserId, session!.getUserId()); + if (linkResp.status !== "OK") { + logDebugMessage(`Could not link the new password to the user: ${linkResp.status}`); + + return { + status: "ERROR", + message: "Could not link the new password to the user. Please contact support.", + }; + } + + return { status: "OK" }; + }), + }, + { + path: HANDLE_BASE_PATH + "/password/change", + method: "post", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + if (!isRecipeInitialized("emailpassword")) { + return { + status: "ERROR", + message: "Changing password requires the EmailPassword recipe to be initialized", + }; + } + + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + const passwordLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "emailpassword"); + if (passwordLoginMethods.length === 0) { + return { + status: "ERROR", + message: "User has no password set. Please set a password first.", + }; + } + if (passwordLoginMethods.length > 1) { + return { + status: "ERROR", + message: "User has multiple password login methods. Please contact support.", + }; + } + + const passwordLoginMethod = passwordLoginMethods[0]!; + + const { currentPassword, newPassword } = await req.getJSONBody(); + + const verifyResult = await verifyCredentials( + session!.getTenantId(), + passwordLoginMethod.email!, + currentPassword, + ); + + if (verifyResult.status !== "OK") { + return { status: "ERROR", message: "Invalid password" }; + } + + const result = await updateEmailOrPassword({ + recipeUserId: passwordLoginMethod.recipeUserId, + password: newPassword, + }); + + if (result.status !== "OK") { + logDebugMessage(`Could not update password: ${result.status}`); + + return { + status: "ERROR", + message: "Password change failed", + }; + } + + return { status: "OK" }; + }), + }, + { + path: HANDLE_BASE_PATH + "/user/unlink", + method: "post", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const { recipeUserId } = await req.getJSONBody(); + + const availableFirstFactors = await getAvailableFirstFactors( + session!.getTenantId(), + session, + userContext, + ); + + const availableUserLoginMethods = user.loginMethods.filter( + (lm) => lm.recipeUserId.getAsString() !== recipeUserId, + ); + const availableUserFactorIds: string[] = availableUserLoginMethods + .map((lm) => { + if (lm.recipeId === "emailpassword") return [FactorIds.EMAILPASSWORD]; + if (lm.recipeId === "passwordless") { + if (lm.email) return [FactorIds.OTP_EMAIL, FactorIds.LINK_EMAIL]; + if (lm.phoneNumber) return [FactorIds.OTP_EMAIL, FactorIds.LINK_EMAIL]; + } + if (lm.recipeId === "thirdparty") return FactorIds.THIRDPARTY; + if (lm.recipeId === "webauthn") return [FactorIds.WEBAUTHN]; + + return undefined; + }) + .filter((factorIds) => factorIds !== undefined) + .flat(); + + const canUnlink = availableUserFactorIds.some((factorId) => availableFirstFactors.includes(factorId)); + if (!canUnlink) { + return { + status: "ERROR", + message: "User has no available first factor login methods", + }; + } + + const result = await deleteUser(recipeUserId, false, { + userContext, + }); + if (result.status !== "OK") { + return { + status: "ERROR", + message: "Could not unlink account", + }; + } + + return { status: "OK" }; + }), + }, + { + path: HANDLE_BASE_PATH + "/mfa/set-required", + method: "post", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const { factorId } = await req.getJSONBody(); + + const requiredFactorIds = await MultiFactorAuth.getRequiredSecondaryFactorsForUser(userId); + for await (const factorId of requiredFactorIds) { + await MultiFactorAuth.removeFromRequiredSecondaryFactorsForUser(userId, factorId); + } + + if (factorId) { + await MultiFactorAuth.addToRequiredSecondaryFactorsForUser(userId, factorId); + } + + return { status: "OK" }; + }), + }, + { + path: HANDLE_BASE_PATH + "/mfa", + method: "get", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const requiredSecondaryFactors = await MultiFactorAuth.getRequiredSecondaryFactorsForUser(user.id); + + return { + status: "OK", + requiredSecondaryFactors, + }; + }), + }, + + { + path: HANDLE_BASE_PATH + "/mfa/update-otp-email", + method: "post", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const { email } = await req.getJSONBody(); + + const emailOtpLoginMethods = user.loginMethods.filter( + (lm) => lm.recipeId === "passwordless" && lm.email, + ); + if (emailOtpLoginMethods.length === 0) { + return { + status: "ERROR", + message: "User has no email OTP login method", + }; + } + if (emailOtpLoginMethods.length > 1) { + return { + status: "ERROR", + message: "User has multiple email OTP login methods", + }; + } + const emailOtpLoginMethod = emailOtpLoginMethods[0]!; + + const result = await Passwordless.updateUser({ + recipeUserId: emailOtpLoginMethod.recipeUserId, + email: email, + }); + + if (result.status !== "OK") { + return { + status: "ERROR", + message: "Failed to update email OTP login method", + }; + } + + return { + status: "OK", + }; + }), + }, + { + path: HANDLE_BASE_PATH + "/mfa/update-otp-phone-number/code", + method: "post", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const phoneOtpLoginMethods = user.loginMethods.filter( + (lm) => lm.recipeId === "passwordless" && lm.phoneNumber, + ); + if (phoneOtpLoginMethods.length === 0) { + return { + status: "ERROR", + message: "User has no phone OTP login method", + }; + } + if (phoneOtpLoginMethods.length > 1) { + return { + status: "ERROR", + message: "User has multiple phone OTP login methods", + }; + } + + const { phoneNumber } = await req.getJSONBody(); + + const result = await Passwordless.createCode({ + phoneNumber, + tenantId: session!.getTenantId(), + session, + userContext, + }); + if (result.status !== "OK") { + return { + status: "ERROR", + message: "Failed to generate code", + }; + } + + await Passwordless.sendSms({ + isFirstFactor: false, + codeLifetime: 1000 * 60 * 5, // todo is this correct? + phoneNumber, + preAuthSessionId: result.preAuthSessionId, + tenantId: session!.getTenantId(), + userContext, + userInputCode: result.userInputCode, + type: "PASSWORDLESS_LOGIN", + }); + + return { + status: "OK", + deviceId: result.deviceId, + preAuthSessionId: result.preAuthSessionId, + }; + }), + }, + { + path: HANDLE_BASE_PATH + "/mfa/update-otp-phone-number", + method: "post", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const phoneOtpLoginMethods = user.loginMethods.filter( + (lm) => lm.recipeId === "passwordless" && lm.phoneNumber, + ); + if (phoneOtpLoginMethods.length === 0) { + return { + status: "ERROR", + message: "User has no phone OTP login method", + }; + } + if (phoneOtpLoginMethods.length > 1) { + return { + status: "ERROR", + message: "User has multiple phone OTP login methods", + }; + } + const phoneOtpLoginMethod = phoneOtpLoginMethods[0]!; + + const { phoneNumber, code, deviceId, preAuthSessionId } = await req.getJSONBody(); + + const checkResult = await Passwordless.checkCode({ + deviceId, + preAuthSessionId, + userInputCode: code, + tenantId: session!.getTenantId(), + userContext, + }); + + if (checkResult.status !== "OK") { + return { + status: "ERROR", + message: "Failed to validate code", + }; + } + if (!checkResult.consumedDevice.phoneNumber) { + return { + status: "ERROR", + message: "Failed to validate code", + }; + } + if (checkResult.consumedDevice.phoneNumber !== phoneNumber) { + return { + status: "ERROR", + message: "Code is not valid for this phone number", + }; + } + + const updateResult = await Passwordless.updateUser({ + recipeUserId: phoneOtpLoginMethod.recipeUserId, + phoneNumber: checkResult.consumedDevice.phoneNumber, + }); + if (updateResult.status !== "OK") { + return { + status: "ERROR", + message: "Failed to update phone OTP login method", + }; + } + + // doesn't matter if it fails or not, since the code we'll revoke itself after a specific time + await Passwordless.revokeAllCodes({ + phoneNumber: checkResult.consumedDevice.phoneNumber, + tenantId: session!.getTenantId(), + userContext, + }); + + return { + status: "OK", + }; + }), + }, + { + path: HANDLE_BASE_PATH + "/mfa/update-totp", + method: "post", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const { name, newName } = await req.getJSONBody(); + + const result = await TOTP.updateDevice(userId, name, newName, userContext); + + if (result.status !== "OK") { + return { + status: "ERROR", + message: "Could not update TOTP device", + }; + } + + return { + status: "OK", + }; + }), + }, + { + path: HANDLE_BASE_PATH + "/user", + method: "get", + verifySessionOptions: { + sessionRequired: true, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + return { + status: "OK", + user: user.toJson(), + }; + }), + }, + ], + }; + }, + }; + }, + undefined, + (pluginConfig) => { + return { + enableSettingPassword: pluginConfig.enableSettingPassword ?? true, + enableThirdPartyLinkning: pluginConfig.enableThirdPartyLinkning ?? true, + enableMfaConfiguration: pluginConfig.enableMfaConfiguration ?? true, + }; + }, +); diff --git a/packages/profile-security-nodejs/src/types.ts b/packages/profile-security-nodejs/src/types.ts new file mode 100644 index 0000000..6a348e4 --- /dev/null +++ b/packages/profile-security-nodejs/src/types.ts @@ -0,0 +1,5 @@ +export type SuperTokensPluginProfileSecurityConfig = { + enableSettingPassword?: boolean; + enableThirdPartyLinkning?: boolean; + enableMfaConfiguration?: boolean; +}; diff --git a/packages/profile-security-nodejs/tsconfig.json b/packages/profile-security-nodejs/tsconfig.json new file mode 100644 index 0000000..06f8f7b --- /dev/null +++ b/packages/profile-security-nodejs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@shared/tsconfig/node.json", + "compilerOptions": { + "lib": ["ES2020"], + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist", + "types": ["node"], + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/profile-security-nodejs/vite.config.ts b/packages/profile-security-nodejs/vite.config.ts new file mode 100644 index 0000000..6582e01 --- /dev/null +++ b/packages/profile-security-nodejs/vite.config.ts @@ -0,0 +1,37 @@ +/// +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; +import peerDepsExternal from "rollup-plugin-peer-deps-external"; +import * as path from "path"; +import packageJson from "./package.json"; + +export default defineConfig(() => ({ + root: __dirname, + plugins: [ + dts({ + entryRoot: "src", + tsconfigPath: path.join(__dirname, "tsconfig.json"), + }), + peerDepsExternal(), + ], + build: { + outDir: "./dist", + emptyOutDir: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: "src/index.ts", + name: packageJson.name, + fileName: "index", + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ["es" as const, "cjs" as const], + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: [], + }, + }, +})); diff --git a/packages/profile-security-nodejs/vitest.config.ts b/packages/profile-security-nodejs/vitest.config.ts new file mode 100644 index 0000000..ad798df --- /dev/null +++ b/packages/profile-security-nodejs/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + preserveSymlinks: true, + }, + test: { + globals: true, + environment: "node", + poolOptions: { + forks: { + singleFork: true, + }, + }, + }, +}); diff --git a/packages/profile-security-react/.eslintrc.js b/packages/profile-security-react/.eslintrc.js new file mode 100644 index 0000000..3c453d2 --- /dev/null +++ b/packages/profile-security-react/.eslintrc.js @@ -0,0 +1,14 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: [require.resolve("@shared/eslint/react.js")], + parserOptions: { + project: true, + }, + rules: { + // Temporarily disable this rule due to a bug with mapped types + "@typescript-eslint/no-unused-vars": "off", + // Disable global type warnings for third-party types + "no-undef": "off", + }, + ignorePatterns: ["**/*.test.ts", "**/*.spec.ts", "tests/**/*"], +}; diff --git a/packages/profile-security-react/.prettierrc.js b/packages/profile-security-react/.prettierrc.js new file mode 100644 index 0000000..8986fc5 --- /dev/null +++ b/packages/profile-security-react/.prettierrc.js @@ -0,0 +1,4 @@ +/** @type {import("prettier").Config} */ +module.exports = { + ...require("@shared/eslint/prettier"), +}; diff --git a/packages/profile-security-react/CHANGELOG.md b/packages/profile-security-react/CHANGELOG.md new file mode 100644 index 0000000..c87affb --- /dev/null +++ b/packages/profile-security-react/CHANGELOG.md @@ -0,0 +1,7 @@ +# @supertokens-plugins/profile-security-react + +## 0.1.0-beta.1 + +### Minor Changes + +- Add the initial security details react plugin diff --git a/packages/profile-security-react/README.md b/packages/profile-security-react/README.md new file mode 100644 index 0000000..1fea09c --- /dev/null +++ b/packages/profile-security-react/README.md @@ -0,0 +1 @@ +# SuperTokens Plugin Security Details diff --git a/packages/profile-security-react/package.json b/packages/profile-security-react/package.json new file mode 100644 index 0000000..ba90e5f --- /dev/null +++ b/packages/profile-security-react/package.json @@ -0,0 +1,46 @@ +{ + "name": "@supertokens-plugins/profile-security-react", + "version": "0.0.1-beta.1", + "description": "Profile Security Plugin for SuperTokens", + "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/profile-security-react/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/supertokens/supertokens-plugins.git", + "directory": "packages/profile-security-react" + }, + "scripts": { + "build": "vite build && npm run pretty", + "pretty": "npx pretty-quick .", + "pretty-check": "npx pretty-quick --check ." + }, + "keywords": [ + "base-profile", + "plugin", + "supertokens" + ], + "dependencies": { + "@shared/js": "*", + "@shared/react": "*", + "@shared/ui": "*", + "supertokens-js-override": "^0.0.4" + }, + "peerDependencies": { + "react": ">=18.3.1", + "react-dom": ">=18.3.1", + "supertokens-auth-react": ">=0.50.0" + }, + "devDependencies": { + "@shared/eslint": "*", + "@shared/tsconfig": "*", + "@types/react": "^17.0.20", + "prettier": "3.5.3", + "pretty-quick": "^4.2.2", + "typescript": "^5.8.3", + "vite-plugin-css-injected-by-js": "^3.5.2" + }, + "browser": { + "fs": false + }, + "module": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/packages/profile-security-react/src/api.ts b/packages/profile-security-react/src/api.ts new file mode 100644 index 0000000..7befd27 --- /dev/null +++ b/packages/profile-security-react/src/api.ts @@ -0,0 +1,126 @@ +import { getQuerier } from "@shared/react"; + +export const getApi = (querier: ReturnType) => { + const getConfig = async () => { + return await querier.get<{ + status: "OK" | "ERROR"; + message?: string; + config: { + enableSettingPassword: boolean; + enableThirdPartyLinkning: boolean; + enableMfaConfiguration: boolean; + }; + }>("/config", { withSession: true }); + }; + + const getUserInfo = async () => { + return await querier.get<{ + user: any; + status: "OK" | "ERROR"; + message?: string; + mfa: { requiredSecondaryFactors: any[] }; + }>("/user", { + withSession: true, + }); + }; + + const setPassword = async (payload: { newPassword: string; email: string }) => { + return await querier.post<{ status: "OK" } | { status: "ERROR"; message: string }>("/password/set", payload, { + withSession: true, + }); + }; + + const changePassword = async (currentPassword: string, newPassword: string) => { + return await querier.post<{ status: "OK" } | { status: "ERROR"; message: string }>( + "/password/change", + { + currentPassword, + newPassword, + }, + { + withSession: true, + }, + ); + }; + + const unlinkAccount = async (recipeUserId: string) => { + return await querier.post<{ status: "OK" } | { status: "ERROR"; message: string }>( + "/user/unlink", + { + recipeUserId, + }, + { + withSession: true, + }, + ); + }; + + const getMfaInfo = async () => { + return await querier.get<{ + status: "OK" | "ERROR"; + message?: string; + requiredSecondaryFactors: any[]; + }>("/mfa", { + withSession: true, + }); + }; + + const setRequiredSecondaryFactor = async (factorId?: string) => { + return await querier.post<{ status: "OK" } | { status: "ERROR"; message: string }>( + "/mfa/set-required", + { + factorId, + }, + { + withSession: true, + }, + ); + }; + + const updateMfaOtpEmail = async (email: string) => { + return await querier.post<{ status: "OK" } | { status: "ERROR"; message: string }>( + "/mfa/update-otp-email", + { email }, + { withSession: true }, + ); + }; + + const updateMfaOtpPhoneNumber = async (payload: { + phoneNumber: string; + code: string; + deviceId: string; + preAuthSessionId: string; + }) => { + return await querier.post<{ status: "OK" } | { status: "ERROR"; message: string }>( + "/mfa/update-otp-phone-number", + payload, + { withSession: true }, + ); + }; + + const sendMfaOtpPhoneNumberCode = async (phoneNumber: string) => { + return await querier.post< + { status: "OK"; deviceId: string; preAuthSessionId: string } | { status: "ERROR"; message: string } + >("/mfa/update-otp-phone-number/code", { phoneNumber }, { withSession: true }); + }; + + const updateMfaTotpName = async (payload: { name: string; newName: string }) => { + return await querier.post<{ status: "OK" } | { status: "ERROR"; message: string }>("/mfa/update-totp", payload, { + withSession: true, + }); + }; + + return { + getConfig, + getUserInfo, + setPassword, + changePassword, + unlinkAccount, + getMfaInfo, + setRequiredSecondaryFactor, + updateMfaOtpEmail, + sendMfaOtpPhoneNumberCode, + updateMfaOtpPhoneNumber, + updateMfaTotpName, + }; +}; diff --git a/packages/profile-security-react/src/components/index.ts b/packages/profile-security-react/src/components/index.ts new file mode 100644 index 0000000..ab668d8 --- /dev/null +++ b/packages/profile-security-react/src/components/index.ts @@ -0,0 +1 @@ +export * from "./security-section"; diff --git a/packages/profile-security-react/src/components/security-section/change-password-section.tsx b/packages/profile-security-react/src/components/security-section/change-password-section.tsx new file mode 100644 index 0000000..318407d --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/change-password-section.tsx @@ -0,0 +1,67 @@ +import { Button, PasswordInput, useToast, usePrettyAction } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useState } from "react"; + +import { usePluginContext } from "../../plugin"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); + +export const ChangePasswordSection = ({ + isLoading, + setIsLoading, +}: { + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; +}) => { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + + const { api, t } = usePluginContext(); + const { addToast } = useToast(); + + const changePassword = usePrettyAction( + async () => { + const res = await api.changePassword(currentPassword, newPassword); + if (res.status !== "OK") { + throw new Error(res.message); + } + + setCurrentPassword(""); + setNewPassword(""); + }, + [currentPassword, newPassword, addToast], + { + errorMessage: t("PL_SEC_CHANGE_PASSWORD_ERROR_CHANGE"), + successMessage: t("PL_SEC_CHANGE_PASSWORD_SUCCESS_CHANGE"), + setLoading: setIsLoading, + }, + ); + + return ( +
+ +
+ +
+
+ +
+ + ); +}; diff --git a/packages/profile-security-react/src/components/security-section/index.ts b/packages/profile-security-react/src/components/security-section/index.ts new file mode 100644 index 0000000..ab668d8 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/index.ts @@ -0,0 +1 @@ +export * from "./security-section"; diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx new file mode 100644 index 0000000..b56f22b --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx @@ -0,0 +1,214 @@ +import { Button, usePrettyAction, SelectInput, TextInput } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useState, useMemo } from "react"; +import { consumeCode, createCode } from "supertokens-auth-react/recipe/passwordless/index.js"; +import { User } from "supertokens-web-js/types"; + +import { usePluginContext } from "../../plugin"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); + +export const MfaFactorEmailOtpConfig = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { + const { api, t } = usePluginContext(); + + const loginMethod = useMemo(() => { + const loginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.email); + if (loginMethods.length === 0) { + console.warn("User has no email OTP login method"); + return null; + } + if (loginMethods.length > 1) { + console.warn("User has multiple email OTP login methods"); + return null; + } + + return loginMethods[0]; + }, [user]); + + const currentEmail = useMemo(() => { + return loginMethod?.email; + }, [loginMethod]); + + const [selectedEmail, setSelectedEmail] = useState(currentEmail || ""); + + const changeEmail = usePrettyAction( + async () => { + if (!selectedEmail) { + return; + } + if (selectedEmail === currentEmail) { + return; + } + const res = await api.updateMfaOtpEmail(selectedEmail); + if (res.status !== "OK") { + throw new Error(res.message); + } + }, + [currentEmail, api, selectedEmail], + { + successMessage: t("PL_SEC_MFA_CHANGE_EMAIL_SUCCESS_CHANGE"), + errorMessage: t("PL_SEC_MFA_CHANGE_EMAIL_ERROR_CHANGE"), + onSuccess: onSuccess, + }, + ); + + if (!loginMethod) { + return
{t("PL_SEC_MFA_ERROR_WRONG_CONFIGURATION")}
; + } + + return ( +
+ { + if (!value) { + return; + } + setSelectedEmail(value as string); + }} + options={user.emails.map((email) => ({ label: email, value: email }))} + /> + +
+ ); +}; + +export const MfaFactorEmailOtpSetup = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { + const { api, t } = usePluginContext(); + + const alreadySetupLoginMethod = useMemo(() => { + const loginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.email); + return loginMethods[0]; + }, [user]); + + const [email, setEmail] = useState(alreadySetupLoginMethod?.email || ""); + const [emailSent, setEmailSent] = useState(false); + const [otp, setOtp] = useState(""); + + const availableEmails = useMemo(() => { + return user.emails.map((email) => ({ label: email, value: email })); + }, [user]); + + const sendEmail = usePrettyAction( + async () => { + setEmailSent(false); + setOtp(""); + + if (!email?.trim()) { + throw new Error(t("PL_SEC_MFA_SETUP_EMAIL_ERROR_EMAIL_INVALID")); + } + const res = await createCode({ + email, + shouldTryLinkingWithSessionUser: true, + }); + + if (res.status !== "OK") { + console.error(res); + throw new Error(t("PL_SEC_MFA_SETUP_EMAIL_ERROR_SEND")); + } + + setEmailSent(true); + }, + [email, api], + { + successMessage: t("PL_SEC_MFA_SETUP_EMAIL_SUCCESS_SEND"), + }, + ); + + const verifyOtp = usePrettyAction( + async () => { + if (!otp) { + return; + } + + const res = await consumeCode({ + userInputCode: otp, + }); + if (res.status !== "OK") { + console.error(res); + throw new Error(t("PL_SEC_MFA_SETUP_EMAIL_ERROR_VERIFY")); + } + + setOtp(""); + }, + [otp, api, onSuccess], + { + successMessage: t("PL_SEC_MFA_SETUP_EMAIL_SUCCESS_VERIFY"), + onSuccess: onSuccess, + }, + ); + + return ( +
+ {!emailSent ? ( + <> + {availableEmails.length > 0 ? ( + { + if (!value) { + return; + } + setEmail(value); + }} + options={availableEmails} + /> + ) : ( + { + if (!value) { + return; + } + setEmail(value); + }} + /> + )} + + + + ) : ( + <> + { + if (!value) { + return; + } + setOtp(value); + }} + /> + + + + )} +
+ ); +}; + +export default { + Config: MfaFactorEmailOtpConfig, + Setup: MfaFactorEmailOtpSetup, +}; diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx new file mode 100644 index 0000000..8c3bdc7 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx @@ -0,0 +1,265 @@ +import { Button, usePrettyAction, TextInput } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useState, useMemo, useCallback } from "react"; +import { consumeCode, createCode } from "supertokens-auth-react/recipe/passwordless/index.js"; +import { User } from "supertokens-web-js/types"; + +import { usePluginContext } from "../../plugin"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); + +export const MfaFactorPhoneOtpConfig = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { + const { api, t } = usePluginContext(); + + const [codeSent, setCodeSent] = useState(false); + const [code, setCode] = useState(""); + const [codeDetails, setCodeDetails] = useState<{ + deviceId: string; + preAuthSessionId: string; + }>(); + + const loginMethod = useMemo(() => { + const loginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.phoneNumber); + if (loginMethods.length === 0) { + console.warn("User has no phone OTP login method"); + return null; + } + if (loginMethods.length > 1) { + console.warn("User has multiple phone OTP login methods"); + return null; + } + + return loginMethods[0]; + }, [user]); + + const currentPhoneNumber = useMemo(() => { + return loginMethod?.phoneNumber; + }, [loginMethod]); + + const [selectedPhoneNumber, setSelectedPhoneNumber] = useState(currentPhoneNumber || ""); + + const resetCode = useCallback(() => { + setCodeSent(false); + setCode(""); + setCodeDetails(undefined); + }, []); + + const sendCode = usePrettyAction( + async () => { + if (selectedPhoneNumber === currentPhoneNumber) { + return; + } + + resetCode(); + + const res = await api.sendMfaOtpPhoneNumberCode(selectedPhoneNumber); + if (res.status !== "OK") { + throw new Error(res.message); + } + setCodeDetails({ + deviceId: res.deviceId, + preAuthSessionId: res.preAuthSessionId, + }); + + setCodeSent(true); + }, + [currentPhoneNumber, api, selectedPhoneNumber], + { + successMessage: t("PL_SEC_MFA_CHANGE_PHONE_NUMBER_SUCCESS_SEND_CODE"), + errorMessage: t("PL_SEC_MFA_CHANGE_PHONE_NUMBER_ERROR_SEND_CODE"), + onSuccess: onSuccess, + onError: async () => { + resetCode(); + }, + }, + ); + + const changePhoneNumber = usePrettyAction( + async () => { + if (!selectedPhoneNumber) { + return; + } + if (selectedPhoneNumber === currentPhoneNumber) { + return; + } + const res = await api.updateMfaOtpPhoneNumber({ + phoneNumber: selectedPhoneNumber, + code, + deviceId: codeDetails!.deviceId, + preAuthSessionId: codeDetails!.preAuthSessionId, + }); + if (res.status !== "OK") { + throw new Error(res.message); + } + + resetCode(); + }, + [currentPhoneNumber, api, selectedPhoneNumber], + { + successMessage: t("PL_SEC_MFA_CHANGE_PHONE_NUMBER_SUCCESS_CHANGE"), + errorMessage: t("PL_SEC_MFA_CHANGE_PHONE_NUMBER_ERROR_CHANGE"), + onSuccess, + }, + ); + + if (!loginMethod) { + return
{t("PL_SEC_MFA_ERROR_WRONG_CONFIGURATION")}
; + } + + return ( +
+ { + if (!value) { + return; + } + setSelectedPhoneNumber(value as string); + }} + /> + {!codeSent ? ( + + ) : ( + <> +
+ setCode(value as string)} /> + + + )} +
+ ); +}; + +export const MfaFactorPhoneOtpSetup = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { + const { api, t } = usePluginContext(); + + const alreadySetupLoginMethod = useMemo(() => { + const loginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.phoneNumber); + return loginMethods[0]; + }, [user]); + + const [phoneNumber, setPhoneNumber] = useState(alreadySetupLoginMethod?.phoneNumber || ""); + const [smsSent, setSmsSent] = useState(false); + const [otp, setOtp] = useState(""); + + const sendSms = usePrettyAction( + async () => { + setSmsSent(false); + setOtp(""); + + if (!phoneNumber?.trim()) { + throw new Error(t("PL_SEC_MFA_SETUP_PHONE_ERROR_PHONE_NUMBER_INVALID")); + } + const res = await createCode({ + phoneNumber, + shouldTryLinkingWithSessionUser: true, + }); + + if (res.status !== "OK") { + console.error(res); + throw new Error(t("PL_SEC_MFA_SETUP_PHONE_ERROR_SEND")); + } + + setSmsSent(true); + }, + [phoneNumber, api], + { + successMessage: t("PL_SEC_MFA_SETUP_PHONE_SUCCESS_SEND"), + }, + ); + + const verifyOtp = usePrettyAction( + async () => { + if (!otp) { + return; + } + + const res = await consumeCode({ + userInputCode: otp, + }); + if (res.status !== "OK") { + console.error(res); + throw new Error(t("PL_SEC_MFA_SETUP_PHONE_ERROR_VERIFY")); + } + + setOtp(""); + }, + [otp, api, onSuccess], + { + successMessage: t("PL_SEC_MFA_SETUP_PHONE_SUCCESS_VERIFY"), + onSuccess: onSuccess, + }, + ); + + return ( +
+ {!smsSent ? ( + <> + { + if (!value) { + return; + } + setPhoneNumber(value); + }} + /> + + + + ) : ( + <> + { + if (!value) { + return; + } + setOtp(value); + }} + /> + + + + )} +
+ ); +}; + +export default { + Config: MfaFactorPhoneOtpConfig, + Setup: MfaFactorPhoneOtpSetup, +}; diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx new file mode 100644 index 0000000..cb60791 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx @@ -0,0 +1,450 @@ +import { Button, Tag, usePrettyAction, TextInput } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useState, useEffect, useMemo } from "react"; +import QRCode from "react-qr-code"; +import { + listDevices, + createDevice, + removeDevice, + verifyDevice, + verifyCode, +} from "supertokens-auth-react/recipe/totp/index.js"; +import { User } from "supertokens-web-js/types"; + +import { usePluginContext } from "../../plugin"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); + +export const MfaFactorTotpConfig = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { + const [totpDevices, setTotpDevices] = useState<{ name: string; verified: boolean }[]>([]); + const [activeTotp, setActiveTotp] = useState<{ + action: "add" | "rename"; + name: string; + qrCodeString: string; + }>(); + const [verifyCode, setVerifyCode] = useState(""); + const [name, setName] = useState(""); + + const { t, api } = usePluginContext(); + + const loadTotps = usePrettyAction(async () => { + const devices = await listDevices(); + if (devices.status !== "OK") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_LOADING_TOTP")); + } + + setTotpDevices( + devices.devices.map((device) => ({ + name: device.name, + verified: device.verified, + })), + ); + }, []); + + const removeTotp = usePrettyAction( + async (deviceName: string) => { + await removeDevice({ deviceName }); + + if (activeTotp?.name === deviceName) { + setActiveTotp(undefined); + } + + loadTotps(); + }, + [activeTotp], + { + successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_REMOVE_TOTP"), + errorMessage: t("PL_SEC_MFA_TOTP_ERROR_REMOVE_TOTP"), + }, + ); + + const addTotp = usePrettyAction( + async () => { + const res = await createDevice({ deviceName: name || undefined }); + if (res.status !== "OK") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP")); + } + setName(""); + setVerifyCode(""); + + loadTotps(); + + setActiveTotp({ + action: "add", + name: res.deviceName, + qrCodeString: res.qrCodeString, + }); + }, + [name], + { + successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_ADD_TOTP"), + errorMessage: t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP"), + }, + ); + + const verifyTotp = usePrettyAction( + async () => { + if (!activeTotp) { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_NO_TOTP_CONFIGURABLE")); + } + if (activeTotp.action !== "add") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_NO_TOTP_CONFIGURABLE")); + } + + if (!verifyCode) { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_NO_CODE")); + } + + const res = await verifyDevice({ + deviceName: name || activeTotp.name, + totp: verifyCode, + }); + if (res.status !== "OK") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE")); + } + + setActiveTotp(undefined); + setVerifyCode(""); + + loadTotps(); + }, + [activeTotp, verifyCode, name], + { + successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_VERIFY_DEVICE"), + errorMessage: t("PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE"), + }, + ); + + const renameTotp = usePrettyAction(async (name: string) => { + setActiveTotp({ action: "rename", name, qrCodeString: "" }); + }, []); + + const updateTotpName = usePrettyAction( + async () => { + if (activeTotp?.action !== "rename") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_NO_TOTP_CONFIGURABLE")); + } + + const res = await api.updateMfaTotpName({ + name: activeTotp.name, + newName: name, + }); + if (res.status !== "OK") { + throw new Error(res.message); + } + + setActiveTotp(undefined); + setName(""); + loadTotps(); + }, + [activeTotp, name], + { + successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_UPDATE_NAME"), + errorMessage: t("PL_SEC_MFA_TOTP_ERROR_UPDATE_NAME"), + }, + ); + + useEffect(() => { + loadTotps(); + }, []); + + const isAdding = !activeTotp || activeTotp.action === "add"; + const isRenaming = Boolean(activeTotp) && activeTotp?.action === "rename"; + + return ( +
+ {totpDevices.map((totp) => ( +
+ {totp.name} + {totp.verified ? ( + + {t("PL_SEC_MFA_TOTP_VERIFIED")} + + ) : ( + + {t("PL_SEC_MFA_TOTP_UNVERIFIED")} + + )} + +
+ + + +
+
+ ))} + + {isRenaming && ( +
+

{t("PL_SEC_MFA_TOTP_RENAME_DEVICE")}

+ +

+ {t("PL_SEC_MFA_TOTP_RENAME_DEVICE_DESCRIPTION")} +

+
+ setName(value as string)} + /> +
+ +
+ )} + + {isAdding && ( +
+

{t("PL_SEC_MFA_TOTP_ADD_DEVICE")}

+ + {!activeTotp && ( + <> +

+ {t("PL_SEC_MFA_TOTP_ADD_DEVICE_DESCRIPTION")} +

+
+
+ setName(value as string)} + /> +
+ +
+ + )} + + {activeTotp && ( + <> +

+ {t("PL_SEC_MFA_TOTP_VERIFY_DESCRIPTION")} +

+ +
+ +
+ +
+ +
+ +
+ setVerifyCode(value as string)} + /> +
+ +
+ + )} +
+ )} +
+ ); +}; + +export const MfaFactorTotpSetup = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { + const [totpDevices, setTotpDevices] = useState<{ name: string; verified: boolean }[]>(); + const [totpDevice, setTotpDevice] = useState<{ + name: string; + qrString: string; + }>(); + const [totp, setTotp] = useState(""); + const [name, setName] = useState(""); + + const { t } = usePluginContext(); + + const loadTotps = usePrettyAction(async () => { + const devices = await listDevices(); + if (devices.status !== "OK") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_LOADING_TOTP")); + } + + setTotpDevices( + devices.devices.map((device) => ({ + name: device.name, + verified: device.verified, + })), + ); + }, []); + + const hasTotpSetup = useMemo(() => { + if (!totpDevices) { + return true; + } + return totpDevices.filter((device) => device.verified).length > 0; + }, [totpDevices]); + + const addTotp = usePrettyAction( + async () => { + const res = await createDevice({ deviceName: name || undefined }); + if (res.status !== "OK") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP")); + } + setName(""); + setTotp(""); + + loadTotps(); + + setTotpDevice({ name: res.deviceName, qrString: res.qrCodeString }); + }, + [name], + { errorMessage: t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP") }, + ); + + const verifyTotp = usePrettyAction( + async () => { + let res: { status: string }; + if (!totpDevice) { + res = await verifyCode({ totp }); + } else { + res = await verifyDevice({ deviceName: name || totpDevice.name, totp }); + } + + if (res.status !== "OK") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE")); + } + + setTotpDevice(undefined); + setTotp(""); + + loadTotps(); + }, + [totpDevice, totp, name], + { + onSuccess, + successMessage: t("PL_SEC_MFA_TOTP_SETUP_SUCCESS_VERIFY_DEVICE"), + errorMessage: t("PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE"), + }, + ); + + useEffect(() => { + loadTotps(); + }, []); + + return ( +
+ {hasTotpSetup ? ( + <> +

{t("PL_SEC_MFA_TOTP_SETUP_CONFIRM_DEVICE")}

+ +
+
+ setTotp(value as string)} + /> +
+ +
+ + ) : ( + <> +

{t("PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE")}

+ + {!totpDevice && ( + <> +

+ {t("PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_DESCRIPTION")} +

+
+
+ setName(value as string)} + /> +
+ +
+ + )} + + {totpDevice && ( + <> +

+ {t("PL_SEC_MFA_TOTP_SETUP_VERIFY_DESCRIPTION")} +

+ + {totpDevice?.qrString && ( + <> +
+
+ +
+ + )} + +
+
+ setTotp(value as string)} + /> +
+ +
+ + )} + + )} +
+ ); +}; + +export default { Config: MfaFactorTotpConfig, Setup: MfaFactorTotpSetup }; diff --git a/packages/profile-security-react/src/components/security-section/mfa-section.tsx b/packages/profile-security-react/src/components/security-section/mfa-section.tsx new file mode 100644 index 0000000..1742f1b --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/mfa-section.tsx @@ -0,0 +1,209 @@ +import { Button, Tag, ToggleInput, usePrettyAction, useToast } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useCallback, useEffect, useState } from "react"; +import { + getSecondaryFactors, + resyncSessionAndFetchMFAInfo, + MultiFactorAuthClaim, +} from "supertokens-auth-react/recipe/multifactorauth/index.js"; +import Session from "supertokens-auth-react/recipe/session/index.js"; +import { User } from "supertokens-web-js/types"; + +import { usePluginContext } from "../../plugin"; +import { TranslationKeys } from "../../types"; + +import MfaFactorEmailOtp from "./mfa-factor-email-otp"; +import MfaFactorPhoneOtp from "./mfa-factor-phone-otp"; +import MfaFactorTotp from "./mfa-factor-totp"; +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); + +const manageFactorComponents = { + "otp-email": MfaFactorEmailOtp, + "otp-phone": MfaFactorPhoneOtp, + totp: MfaFactorTotp, +}; + +export const MfaSection = ({ + user, + isLoading, + setIsLoading, + onSuccess, +}: { + user: User; + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; + onSuccess: () => Promise; +}) => { + const [isLoaded, setIsLoaded] = useState(false); + const [factorBeingSetup, setFactorBeingSetup] = useState(); + + const [secondaryFactors, setSecondaryFactors] = useState< + { + id: string; + name: string; + description: string; + setup: boolean; + required: boolean; + ManageComponent: { + Config: React.ComponentType<{ + user: User; + onSuccess: () => Promise; + }>; + Setup: React.ComponentType<{ + user: User; + onSuccess: () => Promise; + }>; + } | null; + }[] + >([]); + + const { api, t } = usePluginContext(); + const { addToast } = useToast(); + + const loadMfaInfo = usePrettyAction( + async () => { + const mfaInfo = await resyncSessionAndFetchMFAInfo(); + if (mfaInfo.status !== "OK") { + throw new Error(t("PL_SEC_MFA_ERROR_LOADING_MFA_INFO")); + } + + const res = await api.getMfaInfo(); + if (res.status !== "OK") { + throw new Error(res.message); + } + + const mfaClaimValue = await Session.getClaimValue({ + claim: MultiFactorAuthClaim, + }); + + const secondaryFactors = getSecondaryFactors({}) + .filter( + (factor) => + mfaInfo.factors.alreadySetup.includes(factor.id) || mfaInfo.factors.allowedToSetup.includes(factor.id), + ) + .map((factor) => ({ + id: factor.id, + name: factor.name, + description: factor.description, + // make sure that the factor is already setup and the claim is set so we don't trigger a redirect to the factor login screen + setup: mfaInfo.factors.alreadySetup.includes(factor.id) && Boolean(mfaClaimValue?.c[factor.id]), + required: res.requiredSecondaryFactors.includes(factor.id), + ManageComponent: manageFactorComponents[factor.id as keyof typeof manageFactorComponents] ?? null, + })); + setSecondaryFactors(secondaryFactors); + + return { + requiredSecondaryFactors: res.requiredSecondaryFactors, + ...mfaInfo, + }; + }, + [], + { + setLoading: setIsLoading, + }, + ); + + const toggleSecondaryFactor = usePrettyAction( + async (factorId: string) => { + const required = secondaryFactors.find((f) => f.id === factorId)?.required; + const payload = required ? undefined : factorId; + const res = await api.setRequiredSecondaryFactor(payload); + + if (res.status !== "OK") { + throw new Error(res.message); + } + }, + [secondaryFactors, addToast], + { + errorMessage: t("PL_SEC_MFA_ERROR_TOGGLE_SECONDARY_FACTOR"), + successMessage: t("PL_SEC_MFA_SUCCESS_TOGGLE_SECONDARY_FACTOR"), + setLoading: setIsLoading, + onSuccess: () => loadMfaInfo(), + }, + ); + + const _onSuccess = useCallback(async () => { + setFactorBeingSetup(undefined); + + await onSuccess(); + await loadMfaInfo(); + }, [loadMfaInfo, onSuccess]); + + useEffect(() => { + if (isLoaded) { + return; + } + loadMfaInfo(); + setIsLoaded(true); + }, [isLoaded, loadMfaInfo]); + + if (!isLoaded) { + return null; + } + + return ( +
+ {secondaryFactors.map((factor) => ( +
+
+ + {t(factor.name as TranslationKeys)} + + + {factor.setup && ( + + {t("PL_SEC_MFA_SETUP")} + + )} + + {factor.setup && ( + toggleSecondaryFactor(factor.id)} + /> + )} + + {!factor.setup && factorBeingSetup !== factor.id && ( + + )} + + {!factor.setup && factorBeingSetup === factor.id && ( + + )} +
+ + + {t(factor.description as TranslationKeys)} + + + {factor.ManageComponent && factorBeingSetup === factor.id && ( + + )} + {factor.ManageComponent && !factorBeingSetup && factor.required && factor.setup && ( + + )} +
+ ))} +
+ ); +}; diff --git a/packages/profile-security-react/src/components/security-section/security-section.module.css b/packages/profile-security-react/src/components/security-section/security-section.module.css new file mode 100644 index 0000000..f8bac36 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/security-section.module.css @@ -0,0 +1,165 @@ +.plugin-profile-security-section { + display: flex; + flex-direction: column; + max-width: 800px; + margin: 0 auto; + padding: 0; + font-family: var(--plugin-font-family); + color: #333; + width: 100%; +} + +.plugin-profile-security-header { + position: relative; + padding-bottom: 24px; + margin-bottom: 24px; + border-bottom: 1px solid #eee; +} + +.plugin-profile-security-header h3 { + margin-bottom: 12px; +} + +.plugin-profile-security-group { + display: flex; + flex-direction: column; + gap: var(--plugin-spacing-xl); + + padding-bottom: 24px; + margin-bottom: 20px; + + border-bottom: 1px solid #eee; +} + +.plugin-profile-security-group:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.plugin-profile-security-group h3 { + font-size: 16px; + font-weight: 600; + margin: 0 0 8px; +} + +.plugin-profile-security-group h4 { + color: var(--plugin-text-secondary); + font-size: 14px; + font-weight: 600; + margin: 0 0 4px; +} + +.plugin-profile-security-link { + color: #07c; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.plugin-profile-security-linked-account { + display: flex; + align-items: center; + gap: 16px; +} + +.plugin-profile-security-linked-account-provider { + display: flex; + align-items: center; + gap: var(--plugin-spacing-md); + font-weight: 500; +} + +.plugin-profile-security-linked-account-provider-logo { + margin-right: var(--plugin-spacing-md); + line-height: 0; +} + +.plugin-profile-security-linked-account-unlink-button { + margin-left: auto; +} + +.plugin-profile-security-link-account-buttons { + display: flex; + flex-direction: column; + gap: 8px; +} + +.plugin-profile-security-second-factor { + display: flex; + flex-direction: column; + gap: var(--plugin-spacing-2xl); +} + +.plugin-profile-security-second-factor-method-header { + display: flex; + align-items: center; + gap: 8px; +} + +.plugin-profile-security-second-factor-method-action { + margin-left: auto; +} + +.plugin-profile-security-second-factor-method-label { + font-weight: var(--plugin-font-weight-medium); + font-size: var(--plugin-font-size-md); + line-height: var(--plugin-line-height-normal); + color: var(--plugin-text-secondary); +} + +.plugin-profile-security-second-factor-method-description { + color: var(--plugin-text-secondary); + font-size: var(--plugin-font-size-sm); + line-height: var(--plugin-line-height-tight); +} + +.plugin-profile-security-second-factor-method-button { + margin-left: auto; +} + +.plugin-profile-security-manage { + margin-top: var(--plugin-spacing-xl); + padding-left: var(--plugin-spacing-xl); + border-left: 2px solid var(--plugin-color-primary); +} + +.plugin-profile-security-manage-item { + display: flex; + flex-direction: row; + align-items: baseline; + gap: var(--plugin-spacing-md); + margin-bottom: var(--plugin-spacing-md); +} + +.plugin-profile-security-manage-item-actions { + display: flex; + flex-direction: row; + gap: var(--plugin-spacing-md); + margin-left: auto; +} + +.plugin-profile-security-manage-container { + border-top: 1px solid var(--plugin-border-secondary); + padding-top: var(--plugin-spacing-lg); + margin-top: var(--plugin-spacing-lg); +} + +.plugin-profile-security-item-description { + color: var(--plugin-text-secondary); + font-size: var(--plugin-font-size-sm); + line-height: var(--plugin-line-height-tight); +} + +.plugin-profile-security-second-factor-manage-totp-verify-description, +.plugin-profile-security-second-factor-manage-totp-verify-qr { + margin-bottom: var(--plugin-spacing-md); +} + +.plugin-profile-security-second-factor-manage-totp-verify-qr { + display: flex; + align-items: center; + flex-direction: column; +} diff --git a/packages/profile-security-react/src/components/security-section/security-section.tsx b/packages/profile-security-react/src/components/security-section/security-section.tsx new file mode 100644 index 0000000..d1884d6 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/security-section.tsx @@ -0,0 +1,152 @@ +import { usePrettyAction } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useState, useEffect, useMemo } from "react"; +import { isRecipeInitialized } from "supertokens-auth-react"; +import { User } from "supertokens-web-js/types"; + +import { usePluginContext } from "../../plugin"; + +import { ChangePasswordSection } from "./change-password-section"; +import { MfaSection } from "./mfa-section"; +import style from "./security-section.module.css"; +import { SetPasswordSection } from "./set-password-section"; +import { SetWebAuthnSection } from "./set-webauthn-section"; +import { ThirdPartySection } from "./third-party-section"; +import { WebauthnSection } from "./webauthn-section"; + +const cx = classNames.bind(style); + +export const SecurityDetailsSection = () => { + const [isLoaded, setIsLoaded] = useState(false); + const [user, setUser] = useState(); + + const [config, setConfig] = useState({ + enableSettingPassword: false, + enableThirdPartyLinkning: false, + enableMfaConfiguration: false, + }); + + const [isLoading, setIsLoading] = useState(false); + + const { api, t } = usePluginContext(); + + const loadConfig = usePrettyAction(async () => { + const config = await api.getConfig(); + if (config.status !== "OK") { + throw new Error(config.message); + } + setConfig(config.config); + }, []); + + const hasPasswordRecipe = isRecipeInitialized("emailpassword"); + const hasThirdpartyRecipe = isRecipeInitialized("thirdparty"); + const hasMultiFactorAuthRecipe = isRecipeInitialized("multifactorauth"); + const hasWebauthnRecipe = isRecipeInitialized("webauthn"); + + const hasPasswordLoginMethod = useMemo( + () => Boolean(user?.loginMethods.find((lm: any) => lm.recipeId === "emailpassword")), + [user], + ); + + const hasWebauthnLoginMethod = useMemo( + () => Boolean(user?.loginMethods.find((lm: any) => lm.recipeId === "webauthn")), + [user], + ); + + const loadUserInfo = usePrettyAction( + async () => { + const userInfo = await api.getUserInfo(); + if (userInfo.status !== "OK") { + throw new Error(userInfo.message); + } + + setUser(userInfo.user); + + return userInfo; + }, + [], + { + setLoading: setIsLoading, + }, + ); + + useEffect(() => { + if (isLoaded) { + return; + } + + loadConfig() + .then(() => loadUserInfo()) + .then(() => { + setIsLoaded(true); + }); + }, [isLoaded, loadConfig, loadUserInfo]); + + return ( +
+
+

{t("PL_SEC_HEADER_TITLE")}

+

{t("PL_SEC_HEADER_DESCRIPTION")}

+
+ +
+ {hasPasswordRecipe && hasPasswordLoginMethod && ( +
+

{t("PL_SEC_CHANGE_PASSWORD_TITLE")}

+ +
+ )} + + {config?.enableSettingPassword && hasPasswordRecipe && !hasPasswordLoginMethod && ( +
+

{t("PL_SEC_SET_PASSWORD_TITLE")}

+ +
+ )} + + {hasWebauthnRecipe && !hasWebauthnLoginMethod && ( +
+

{t("PL_SEC_SET_WEBAUTHN_TITLE")}

+ +
+ )} + + {hasWebauthnRecipe && hasWebauthnLoginMethod && ( +
+

{t("PL_SEC_WEBAUTHN_TITLE")}

+ +
+ )} + + {config?.enableThirdPartyLinkning && hasThirdpartyRecipe && ( +
+

{t("PL_SEC_TP_TITLE")}

+ +
+ )} + + {config?.enableMfaConfiguration && hasMultiFactorAuthRecipe && ( +
+

{t("PL_SEC_MFA_TITLE")}

+ +
+ )} +
+
+ ); +}; diff --git a/packages/profile-security-react/src/components/security-section/set-password-section.tsx b/packages/profile-security-react/src/components/security-section/set-password-section.tsx new file mode 100644 index 0000000..b9165c0 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/set-password-section.tsx @@ -0,0 +1,95 @@ +import { Button, SelectInput, PasswordInput, useToast, usePrettyAction } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useEffect, useState } from "react"; +import { User } from "supertokens-web-js/types"; + +import { usePluginContext } from "../../plugin"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); + +export const SetPasswordSection = ({ + user, + isLoading, + setIsLoading, + onSuccess, +}: { + user: User; + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; + onSuccess: () => Promise; +}) => { + const [passwordSetEmail, setPasswordSetEmail] = useState(""); + const [newPassword, setNewPassword] = useState(""); + + const { api, t } = usePluginContext(); + const { addToast } = useToast(); + + const setPassword = usePrettyAction( + async () => { + const res = await api.setPassword({ + newPassword, + email: passwordSetEmail, + }); + if (res.status !== "OK") { + throw new Error(res.message); + } + + setNewPassword(""); + }, + [newPassword, addToast, passwordSetEmail], + { + errorMessage: t("PL_SEC_SET_PASSWORD_ERROR_MESSAGE"), + successMessage: t("PL_SEC_SET_PASSWORD_SUCCESS_MESSAGE"), + onSuccess: onSuccess, + setLoading: setIsLoading, + }, + ); + + useEffect(() => { + if (!user) { + return; + } + + if (!user.loginMethods.find((lm: any) => lm.recipeId === "emailpassword") && user.emails.length) { + setPasswordSetEmail(user.emails?.[0] ?? ""); + } + }, [user]); + + if (!user) { + return null; + } + + return ( +
+ { + if (!value) { + return; + } + setPasswordSetEmail(value as string); + }} + options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} + disabled={(user?.emails.length ?? 0) <= 1} + /> +
+ +
+
+ +
+ + ); +}; diff --git a/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx b/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx new file mode 100644 index 0000000..57dcad6 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx @@ -0,0 +1,80 @@ +import { Button, SelectInput, usePrettyAction } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useEffect, useState } from "react"; +import { registerCredentialWithSignUp } from "supertokens-auth-react/recipe/webauthn"; +import { User } from "supertokens-web-js/types"; + +import { usePluginContext } from "../../plugin"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); +export const SetWebAuthnSection = ({ + user, + isLoading, + setIsLoading, + onSuccess, +}: { + user: User; + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; + onSuccess: () => Promise; +}) => { + const [webauthnSetEmail, setWebauthnSetEmail] = useState(""); + + const { t } = usePluginContext(); + + const setWebauthn = usePrettyAction( + async () => { + await registerCredentialWithSignUp({ + email: webauthnSetEmail, + userContext: {}, + }); + }, + [webauthnSetEmail], + { + successMessage: t("PL_SEC_SET_WEBAUTHN_SUCCESS_SET_CREDENTIAL"), + errorMessage: t("PL_SEC_SET_WEBAUTHN_ERROR_SET_CREDENTIAL"), + onSuccess, + setLoading: setIsLoading, + }, + ); + + useEffect(() => { + if (!user) { + return; + } + + if (!user.loginMethods.find((lm: any) => lm.recipeId === "webauthn") && user.emails.length) { + setWebauthnSetEmail(user.emails?.[0] ?? ""); + } + }, [user]); + + if (!user) { + return null; + } + + return ( +
+ { + if (!value) { + return; + } + setWebauthnSetEmail(value as string); + }} + options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} + disabled={(user?.emails.length ?? 0) <= 1} + /> +
+
+ +
+ + ); +}; diff --git a/packages/profile-security-react/src/components/security-section/third-party-section.tsx b/packages/profile-security-react/src/components/security-section/third-party-section.tsx new file mode 100644 index 0000000..ebd0633 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/third-party-section.tsx @@ -0,0 +1,182 @@ +import { Button, usePrettyAction, useToast } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useMemo } from "react"; +import { + redirectToThirdPartyLogin, + Apple, + Facebook, + Github, + Gitlab, + Google, + LinkedIn, + Twitter, + Bitbucket, + Discord, + ActiveDirectory, + GoogleWorkspaces, + Okta, + BoxySAML, + getProviders, +} from "supertokens-auth-react/recipe/thirdparty"; +import { User } from "supertokens-web-js/types"; + +import { usePluginContext } from "../../plugin"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); + +export const thirdPartyIdToProviderMap = { + google: Google.init(), + apple: Apple.init(), + facebook: Facebook.init(), + github: Github.init(), + linkedin: LinkedIn.init(), + twitter: Twitter.init(), + gitlab: Gitlab.init(), + "active-directory": ActiveDirectory.init(), + "boxy-saml": BoxySAML.init(), + discord: Discord.init(), + okta: Okta.init(), + "google-workspaces": GoogleWorkspaces.init(), + bitbucket: Bitbucket.init(), +}; + +export const ThirdPartySection = ({ + user, + isLoading, + setIsLoading, + onSuccess, +}: { + user: User; + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; + onSuccess: () => Promise; +}) => { + const { api, t } = usePluginContext(); + const { addToast } = useToast(); + + const availableSignUpProviders = useMemo(() => { + if (!user) { + return []; + } + + return getProviders() + .filter((provider) => { + const loginMethod = user.loginMethods.find((lm: any) => lm.thirdParty && lm.thirdParty.id === provider.id); + return !loginMethod; + }) + .map((provider) => ({ name: provider.name, id: provider.id })); + }, [user]); + + const connectedAccounts = useMemo(() => { + if (!user) { + return []; + } + + return user.loginMethods + .filter((method) => method.thirdParty) + .map((method) => ({ + providerId: method.thirdParty!.id, + email: method.email!, + recipeUserId: method.recipeUserId, + })); + }, [user]); + + const linkAccount = usePrettyAction( + async (providerId: string) => { + const res = await redirectToThirdPartyLogin({ + thirdPartyId: providerId, + shouldTryLinkingWithSessionUser: true, + }); + if (res.status !== "OK") { + throw new Error( + t("PL_SEC_TP_ERROR_LINK_ACCOUNT", { + provider: thirdPartyIdToProviderMap[providerId as keyof typeof thirdPartyIdToProviderMap].name, + }), + ); + } + }, + [], + { + errorMessage: (e, providerId) => + t("PL_SEC_TP_ERROR_LINK_ACCOUNT", { + provider: thirdPartyIdToProviderMap[providerId as keyof typeof thirdPartyIdToProviderMap].name, + }), + onSuccess, + setLoading: setIsLoading, + }, + ); + + const unlinkAccount = usePrettyAction( + async (recipeUserId: string) => { + const res = await api.unlinkAccount(recipeUserId); + if (res.status !== "OK") { + throw new Error(res.message); + } + }, + [addToast], + { + errorMessage: (e, recipeUserId) => { + const provider = connectedAccounts.find((account) => account.recipeUserId === recipeUserId)?.providerId; + return t("PL_SEC_TP_ERROR_UNLINK_ACCOUNT", { + provider: thirdPartyIdToProviderMap[provider as keyof typeof thirdPartyIdToProviderMap].name ?? "", + }); + }, + successMessage: (recipeUserId) => { + const provider = connectedAccounts.find((account) => account.recipeUserId === recipeUserId)?.providerId; + return t("PL_SEC_TP_SUCCESS_UNLINK_ACCOUNT", { + provider: thirdPartyIdToProviderMap[provider as keyof typeof thirdPartyIdToProviderMap].name ?? "", + }); + }, + onSuccess, + setLoading: setIsLoading, + }, + ); + + return ( + <> + {connectedAccounts.length === 0 && ( + {t("PL_SEC_TP_NO_LINKED_ACCOUNTS")} + )} + {connectedAccounts.map((account, index) => ( +
+ + {thirdPartyIdToProviderMap[account.providerId as keyof typeof thirdPartyIdToProviderMap].getLogo()} + {thirdPartyIdToProviderMap[account.providerId as keyof typeof thirdPartyIdToProviderMap].name} + + {account.email} + +
+ ))} + + {availableSignUpProviders.length > 0 && ( +
+ {availableSignUpProviders.map((provider) => ( + + ))} +
+ )} + + ); +}; diff --git a/packages/profile-security-react/src/components/security-section/webauthn-section.tsx b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx new file mode 100644 index 0000000..4e0da22 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx @@ -0,0 +1,186 @@ +import { Button, SelectInput, usePrettyAction } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + listCredentials, + removeCredential, + createAndRegisterCredentialForSessionUser, +} from "supertokens-auth-react/recipe/webauthn"; +import { User } from "supertokens-web-js/types"; + +import { usePluginContext } from "../../plugin"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); +export const WebauthnSection = ({ + user, + isLoading, + setIsLoading, + onSuccess, +}: { + user: User; + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; + onSuccess: () => Promise; +}) => { + const [credentials, setCredentials] = useState< + { + webauthnCredentialId: string; + relyingPartyId: string; + recipeUserId: string; + createdAt: number; + }[] + >([]); + const [webauthnEmail, setWebauthnEmail] = useState(""); + + const { t } = usePluginContext(); + + useEffect(() => { + if (!user) { + return; + } + + const email = user.loginMethods.find((lm: any) => lm.recipeId === "webauthn")?.email; + if (email) { + setWebauthnEmail(email); + } + + loadWebAuthn(); + }, [user]); + + const webAuthnEmails = useMemo(() => { + return user?.loginMethods.filter((lm: any) => lm.recipeId === "webauthn").map((lm: any) => lm.email) ?? []; + }, [user]); + + const loadWebAuthn = usePrettyAction( + async () => { + const result = await listCredentials({ userContext: {} }); + if (result.status === "OK") { + setCredentials(result.credentials); + } else { + throw new Error("Failed to load Passkeys"); + } + }, + [], + { + errorMessage: t("PL_SEC_WEBAUTHN_ERROR_LOAD_CREDENTIALS"), + setLoading: setIsLoading, + }, + ); + + const _onSuccess = useCallback(async () => { + await loadWebAuthn(); + await onSuccess(); + }, [onSuccess]); + + const _removeCredential = usePrettyAction( + async (webauthnCredentialId: string) => { + const result = await removeCredential({ + webauthnCredentialId, + userContext: {}, + }); + if (result.status === "OK") { + setCredentials(credentials.filter((c) => c.webauthnCredentialId !== webauthnCredentialId)); + } else { + throw new Error("Failed to remove Passkey"); + } + }, + [], + { + onSuccess: _onSuccess, + errorMessage: t("PL_SEC_WEBAUTHN_ERROR_REMOVE_CREDENTIAL"), + setLoading: setIsLoading, + }, + ); + + const addCredential = usePrettyAction( + async () => { + // assume only one webauthn user + const recipeUserId = user.loginMethods.find( + (lm: any) => lm.recipeId === "webauthn" && lm.email === webauthnEmail, + )?.recipeUserId; + if (!recipeUserId) { + throw new Error("Could not find user"); + } + + const registerCredentialResult = await createAndRegisterCredentialForSessionUser({ + recipeUserId: recipeUserId!, + email: webauthnEmail, + userContext: {}, + }); + + if (registerCredentialResult.status !== "OK") { + throw new Error("Failed to add Passkey"); + } + + await loadWebAuthn(); + }, + [webauthnEmail, user], + { + errorMessage: t("PL_SEC_WEBAUTHN_ERROR_ADD_CREDENTIAL"), + successMessage: t("PL_SEC_WEBAUTHN_SUCCESS_ADD_CREDENTIAL"), + setLoading: setIsLoading, + onSuccess: _onSuccess, + }, + ); + + if (!user) { + return null; + } + + return ( +
+ {credentials.map((credential) => ( +
+ + {webauthnEmail} + {t("PL_SEC_DOT_SEPARATOR")} + {new Date(credential.createdAt).toLocaleString()} + + +
+ +
+
+ ))} + +
+

{t("PL_SEC_WEBAUTHN_ADD_CREDENTIAL_TITLE")}

+ +

+ {t("PL_SEC_WEBAUTHN_ADD_CREDENTIAL_DESCRIPTION")} +

+
+ + { + if (!value) { + return; + } + setWebauthnEmail(value as string); + }} + options={webAuthnEmails.map((email) => ({ label: email, value: email })) ?? []} + disabled={webAuthnEmails.length <= 1} + /> +
+
+ +
+
+
+ ); +}; diff --git a/packages/profile-security-react/src/constants.ts b/packages/profile-security-react/src/constants.ts new file mode 100644 index 0000000..3ae06c8 --- /dev/null +++ b/packages/profile-security-react/src/constants.ts @@ -0,0 +1,4 @@ +export const PLUGIN_ID = "supertokens-plugin-profile-security"; +export const PLUGIN_VERSION = "0.0.1"; + +export const API_PATH = `plugin/${PLUGIN_ID}`; diff --git a/packages/profile-security-react/src/css.d.ts b/packages/profile-security-react/src/css.d.ts new file mode 100644 index 0000000..93c8235 --- /dev/null +++ b/packages/profile-security-react/src/css.d.ts @@ -0,0 +1,29 @@ +declare module "*.module.css" { + const classes: { [key: string]: string }; + export default classes; +} + +declare module "*.module.scss" { + const classes: { [key: string]: string }; + export default classes; +} + +declare module "*.module.sass" { + const classes: { [key: string]: string }; + export default classes; +} + +declare module "*.module.less" { + const classes: { [key: string]: string }; + export default classes; +} + +declare module "*.module.styl" { + const classes: { [key: string]: string }; + export default classes; +} + +declare module "*.css" { + const css: string; + export default css; +} diff --git a/packages/profile-security-react/src/index.ts b/packages/profile-security-react/src/index.ts new file mode 100644 index 0000000..4f6b045 --- /dev/null +++ b/packages/profile-security-react/src/index.ts @@ -0,0 +1,5 @@ +import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; +import { init, usePluginContext } from "./plugin"; + +export { init, usePluginContext, PLUGIN_ID, PLUGIN_VERSION }; +export default { init, usePluginContext, PLUGIN_ID, PLUGIN_VERSION }; diff --git a/packages/profile-security-react/src/logger.ts b/packages/profile-security-react/src/logger.ts new file mode 100644 index 0000000..37cdd77 --- /dev/null +++ b/packages/profile-security-react/src/logger.ts @@ -0,0 +1,5 @@ +import { buildLogger } from "@shared/react"; + +import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; + +export const { logDebugMessage, enableDebugLogs } = buildLogger(PLUGIN_ID, PLUGIN_VERSION); diff --git a/packages/profile-security-react/src/plugin.ts b/packages/profile-security-react/src/plugin.ts new file mode 100644 index 0000000..2821019 --- /dev/null +++ b/packages/profile-security-react/src/plugin.ts @@ -0,0 +1,123 @@ +import { createPluginInitFunction } from "@shared/js"; +import { buildContext, getQuerier } from "@shared/react"; +import { FlashToastKey } from "@shared/ui"; +import { + getTranslationFunction, + SuperTokensPlugin, + SuperTokensPublicConfig, + SuperTokensPublicPlugin, +} from "supertokens-auth-react"; +import { MultiFactorAuthPreBuiltUI } from "supertokens-auth-react/recipe/multifactorauth/prebuiltui"; + +import { getApi } from "./api"; +import { thirdPartyIdToProviderMap } from "./components/security-section/third-party-section"; +import { API_PATH, PLUGIN_ID } from "./constants"; +import { enableDebugLogs, logDebugMessage } from "./logger"; +import { SecuritySectionWrapper } from "./security-section-wrapper"; +import { defaultTranslationsSecurity } from "./translations"; +import { SuperTokensPluginProfileSecurityConfig, TranslationKeys } from "./types"; + +const { usePluginContext, setContext } = buildContext<{ + plugins: SuperTokensPublicPlugin[]; + sdkVersion: string; + appConfig: SuperTokensPublicConfig; + pluginConfig: SuperTokensPluginProfileSecurityConfig; + querier: ReturnType; + api: ReturnType; + t: (key: TranslationKeys, replacements?: Record) => string; +}>(); + +export { usePluginContext }; + +export const init = createPluginInitFunction( + (pluginConfig) => { + let t: (key: TranslationKeys, replacements?: Record) => string = (key) => key; + + return { + id: PLUGIN_ID, + overrideMap: { + thirdparty: { + functions: (originalImplementation) => ({ + ...originalImplementation, + signInAndUp: async (input) => { + const state = originalImplementation.getStateAndOtherInfoFromStorage(input); + + const result = await originalImplementation.signInAndUp(input); + if (!state?.shouldTryLinkingWithSessionUser) { + return result; + } + + const providerName = + thirdPartyIdToProviderMap[state?.thirdPartyId as keyof typeof thirdPartyIdToProviderMap].name; + + if (result.status === "OK") { + window.location.href = `/user/profile#security?${FlashToastKey.Success}=${encodeURIComponent( + t("PL_SEC_TP_SUCCESS_LINK_ACCOUNT", { + provider: providerName ?? "", + }), + )}`; + } else { + window.location.href = `/user/profile#security?${FlashToastKey.Error}=${encodeURIComponent( + t("PL_SEC_TP_ERROR_LINK_ACCOUNT", { + provider: providerName ?? "", + }), + )}`; + } + + return result; + }, + }), + }, + }, + // even though this is async, it will not be awaited by the sdk + init: async (appConfig, plugins, sdkVersion) => { + if (appConfig.enableDebugLogs) { + enableDebugLogs(); + } + + const baseProfilePlugin: SuperTokensPlugin | undefined = plugins.find( + (plugin) => plugin.id === "supertokens-plugin-profile-base", + ); + if (!baseProfilePlugin) { + logDebugMessage("Base profile plugin not found. Not adding common details profile plugin."); + return; + } + + if (!baseProfilePlugin.exports) { + logDebugMessage("Base profile plugin does not export anything. Not adding common details profile plugin."); + return; + } + + const registerSection = baseProfilePlugin.exports?.registerSection; + if (!registerSection) { + logDebugMessage( + "Base profile plugin does not export registerSection. Not adding common details profile plugin.", + ); + return; + } + + const querier = getQuerier(new URL(API_PATH, appConfig.appInfo.apiDomain.getAsStringDangerous()).toString()); + + const recipeTranslationStores = [MultiFactorAuthPreBuiltUI.languageTranslations]; + t = getTranslationFunction(...recipeTranslationStores, defaultTranslationsSecurity); + + setContext({ + plugins, + sdkVersion, + appConfig, + pluginConfig, + querier, + api: getApi(querier), + t, + }); + + await registerSection(async () => ({ + id: "security", + title: t("PL_SEC_HEADER_TITLE"), + order: 999, // last section + component: () => SecuritySectionWrapper.call(null), + })); + }, + }; + }, +); diff --git a/packages/profile-security-react/src/security-section-wrapper.tsx b/packages/profile-security-react/src/security-section-wrapper.tsx new file mode 100644 index 0000000..46c21b9 --- /dev/null +++ b/packages/profile-security-react/src/security-section-wrapper.tsx @@ -0,0 +1,12 @@ +import { ToastProvider, ToastContainer } from "@shared/ui"; + +import { SecurityDetailsSection } from "./components"; + +export const SecuritySectionWrapper = () => { + return ( + + + + + ); +}; diff --git a/packages/profile-security-react/src/translations.ts b/packages/profile-security-react/src/translations.ts new file mode 100644 index 0000000..951cef0 --- /dev/null +++ b/packages/profile-security-react/src/translations.ts @@ -0,0 +1,138 @@ +export const defaultTranslationsSecurity = { + en: { + PL_SEC_HEADER_TITLE: "Security", + PL_SEC_HEADER_DESCRIPTION: "Manage your security settings", + PL_SEC_DOT_SEPARATOR: "·", + + PL_SEC_SET_PASSWORD_TITLE: "Set password", + PL_SEC_SET_PASSWORD_BUTTON: "Set password", + PL_SEC_SET_PASSWORD_PASSWORD_LABEL: "Password", + PL_SEC_SET_PASSWORD_PASSWORD_PLACEHOLDER: "Enter your password", + PL_SEC_SET_PASSWORD_SELECT_EMAIL_LABEL: "Select email", + PL_SEC_SET_PASSWORD_ERROR_MESSAGE: "Failed to set password", + PL_SEC_SET_PASSWORD_SUCCESS_MESSAGE: "Password set successfully", + + PL_SEC_CURRENT_PASSWORD_LABEL: "Current password", + PL_SEC_CURRENT_PASSWORD_PLACEHOLDER: "Enter your current password", + PL_SEC_NEW_PASSWORD_LABEL: "New password", + PL_SEC_NEW_PASSWORD_PLACEHOLDER: "Enter your new password", + PL_SEC_CHANGE_PASSWORD_TITLE: "Change password", + PL_SEC_CHANGE_PASSWORD_BUTTON: "Change password", + + PL_SEC_CONFIRM_NEW_PASSWORD: "Confirm new password", + PL_SEC_CHANGE_PASSWORD_SUCCESS_CHANGE: "Password changed successfully", + PL_SEC_CHANGE_PASSWORD_ERROR_CHANGE: "Failed to change password", + + PL_SEC_TP_LINK_ACCOUNT_BUTTON: "Link", + PL_SEC_TP_UNLINK_ACCOUNT_BUTTON: "Unlink", + PL_SEC_TP_TITLE: "Linked accounts", + PL_SEC_TP_NO_LINKED_ACCOUNTS: "No linked accounts", + PL_SEC_TP_SUCCESS_LINK_ACCOUNT: "{provider} account linked successfully", + PL_SEC_TP_SUCCESS_UNLINK_ACCOUNT: "{provider} account unlinked successfully", + PL_SEC_TP_ERROR_LINK_ACCOUNT: + "Failed to link {provider} account. Please make sure it is not already linked to another account.", + PL_SEC_TP_ERROR_UNLINK_ACCOUNT: "Failed to unlink account", + + PL_SEC_MFA_SETUP: "Setup", + PL_SEC_MFA_SETUP_BUTTON: "Setup", + PL_SEC_MFA_SETUP_CANCEL_BUTTON: "Cancel", + PL_SEC_MFA_TITLE: "Two-factor authentication", + PL_SEC_MFA_SUCCESS_TOGGLE_SECONDARY_FACTOR: "Secondary factor changed successfully", + PL_SEC_MFA_ERROR_LOADING_MFA_INFO: "Failed to load MFA info", + PL_SEC_MFA_ERROR_TOGGLE_SECONDARY_FACTOR: "Failed to change secondary factor", + PL_SEC_MFA_ERROR_WRONG_CONFIGURATION: "Wrong MFA configuration. Please contact support.", + + PL_SEC_MFA_CHANGE_PHONE_NUMBER_LABEL: "Change phone number", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_SEND_CODE_BUTTON: "Send code", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_CHANGE_BUTTON: "Change phone number", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_SUCCESS_CHANGE: "Phone number changed successfully", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_SUCCESS_SEND_CODE: "A code has been sent to your phone", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_ERROR_CHANGE: "Failed to change phone number", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_ERROR_SEND_CODE: "Failed to send code", + PL_SEC_MFA_SETUP_PHONE_LABEL: "Phone number", + PL_SEC_MFA_SETUP_PHONE_PLACEHOLDER: "Enter your phone number", + PL_SEC_MFA_SETUP_PHONE_SEND_BUTTON: "Send code", + PL_SEC_MFA_SETUP_PHONE_CODE_LABEL: "Code", + PL_SEC_MFA_SETUP_PHONE_CODE_PLACEHOLDER: "The code you received on your phone", + PL_SEC_MFA_SETUP_PHONE_VERIFY_BUTTON: "Verify", + PL_SEC_MFA_SETUP_PHONE_ERROR_PHONE_NUMBER_INVALID: "Phone number is invalid", + PL_SEC_MFA_SETUP_PHONE_ERROR_SEND: "Failed to send code", + PL_SEC_MFA_SETUP_PHONE_SUCCESS_SEND: "A code has been sent to your phone number", + PL_SEC_MFA_SETUP_PHONE_ERROR_VERIFY: "Failed to verify code", + PL_SEC_MFA_SETUP_PHONE_SUCCESS_VERIFY: "Phone number verified successfully. You can now enable this factor.", + + PL_SEC_MFA_TOTP_VERIFIED: "Verified", + PL_SEC_MFA_TOTP_UNVERIFIED: "Unverified", + PL_SEC_MFA_TOTP_ADD_BUTTON: "Add TOTP", + PL_SEC_MFA_TOTP_ADD_DEVICE: "Add device", + PL_SEC_MFA_TOTP_ADD_DEVICE_DESCRIPTION: "Enter a name for your device", + PL_SEC_MFA_TOTP_ADD_DEVICE_NAME_LABEL: "Name", + PL_SEC_MFA_TOTP_ADD_DEVICE_NAME_PLACEHOLDER: "If not provided, a default name will be used.", + PL_SEC_MFA_TOTP_REMOVE_BUTTON: "Remove", + PL_SEC_MFA_TOTP_RENAME_BUTTON: "Rename", + PL_SEC_MFA_TOTP_RENAME_DEVICE: "Rename device", + PL_SEC_MFA_TOTP_RENAME_DEVICE_DESCRIPTION: "Enter a new name for your device", + PL_SEC_MFA_TOTP_RENAME_DEVICE_NAME_LABEL: "Name", + PL_SEC_MFA_TOTP_RENAME_DEVICE_NAME_PLACEHOLDER: "Name of the device", + PL_SEC_MFA_TOTP_VERIFY_DESCRIPTION: "Scan the QR code with your authenticator app", + PL_SEC_MFA_TOTP_VERIFY_BUTTON: "Verify", + PL_SEC_MFA_TOTP_VERIFY_CODE_LABEL: "Code", + PL_SEC_MFA_TOTP_VERIFY_CODE_PLACEHOLDER: "Enter the code from your authenticator app", + PL_SEC_MFA_TOTP_SUCCESS_REMOVE_TOTP: "TOTP device removed successfully", + PL_SEC_MFA_TOTP_SUCCESS_ADD_TOTP: "TOTP device added successfully", + PL_SEC_MFA_TOTP_SUCCESS_VERIFY_DEVICE: "TOTP device verified successfully", + PL_SEC_MFA_TOTP_SUCCESS_UPDATE_NAME: "Name updated successfully", + PL_SEC_MFA_TOTP_ERROR_LOADING_TOTP: "Failed to load TOTP devices", + PL_SEC_MFA_TOTP_ERROR_REMOVE_TOTP: "Failed to remove TOTP device", + PL_SEC_MFA_TOTP_ERROR_ADD_TOTP: "Failed to add TOTP device", + PL_SEC_MFA_TOTP_ERROR_NO_TOTP_CONFIGURABLE: "No valid TOTP configurable", + PL_SEC_MFA_TOTP_ERROR_NO_CODE: "Please enter a code", + PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE: "Failed to verify device", + PL_SEC_MFA_TOTP_ERROR_UPDATE_NAME: "Failed to change name", + PL_SEC_MFA_TOTP_SETUP_SUCCESS_VERIFY_DEVICE: "TOTP device verified successfully. You can now enable this factor.", + PL_SEC_MFA_TOTP_SETUP_CONFIRM_DEVICE: "Confirm device", + PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE: "Add device", + PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_DESCRIPTION: "Scan the QR code with your authenticator app", + PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_NAME_LABEL: "Name", + PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_NAME_PLACEHOLDER: "If not provided, a default name will be used.", + PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_BUTTON: "Add device", + PL_SEC_MFA_TOTP_SETUP_VERIFY_DESCRIPTION: "Enter the code from your authenticator app", + PL_SEC_MFA_TOTP_SETUP_VERIFY_BUTTON: "Verify", + PL_SEC_MFA_TOTP_SETUP_VERIFY_CODE_LABEL: "Code", + PL_SEC_MFA_TOTP_SETUP_VERIFY_CODE_PLACEHOLDER: "Enter the code from your authenticator app", + + PL_SEC_MFA_CHANGE_EMAIL_LABEL: "Change email", + PL_SEC_MFA_CHANGE_EMAIL_BUTTON: "Change email", + PL_SEC_MFA_CHANGE_EMAIL_SUCCESS_CHANGE: "Email changed successfully", + PL_SEC_MFA_CHANGE_EMAIL_ERROR_CHANGE: "Failed to change email", + + PL_SEC_MFA_SETUP_EMAIL_LABEL: "Email", + PL_SEC_MFA_SETUP_EMAIL_PLACEHOLDER: "Enter your email", + PL_SEC_MFA_SETUP_EMAIL_SEND_BUTTON: "Send code", + PL_SEC_MFA_SETUP_EMAIL_SUCCESS_SEND: "A code has been sent to your email", + PL_SEC_MFA_SETUP_EMAIL_ERROR_EMAIL_INVALID: "Email is invalid", + PL_SEC_MFA_SETUP_EMAIL_ERROR_SEND: "Failed to send code", + PL_SEC_MFA_SETUP_EMAIL_CODE_LABEL: "Code", + PL_SEC_MFA_SETUP_EMAIL_CODE_PLACEHOLDER: "Enter the code from your email", + PL_SEC_MFA_SETUP_EMAIL_VERIFY_BUTTON: "Verify", + PL_SEC_MFA_SETUP_EMAIL_ERROR_VERIFY: "Failed to verify code", + PL_SEC_MFA_SETUP_EMAIL_SUCCESS_VERIFY: "Email verified successfully. You can now enable this factor.", + + PL_SEC_SET_WEBAUTHN_TITLE: "Passkeys", + PL_SEC_SET_WEBAUTHN_BUTTON: "Add Passkey", + PL_SEC_SET_WEBAUTHN_SELECT_EMAIL_LABEL: "Select email", + PL_SEC_SET_WEBAUTHN_SUCCESS_SET_CREDENTIAL: "Passkey added successfully", + PL_SEC_SET_WEBAUTHN_ERROR_SET_CREDENTIAL: "Failed to add Passkey", + + PL_SEC_WEBAUTHN_TITLE: "Passkeys", + PL_SEC_WEBAUTHN_ERROR_ADD_CREDENTIAL: "Failed to add Passkey", + PL_SEC_WEBAUTHN_SUCCESS_ADD_CREDENTIAL: "Passkey added successfully", + PL_SEC_WEBAUTHN_ERROR_REMOVE_CREDENTIAL: "Failed to remove Passkey", + PL_SEC_WEBAUTHN_ERROR_LOAD_CREDENTIALS: "Failed to load Passkeys", + PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL: "Select email", + PL_SEC_WEBAUTHN_REMOVE_BUTTON: "Remove", + PL_SEC_WEBAUTHN_ADD_CREDENTIAL_BUTTON: "Add Passkey", + PL_SEC_WEBAUTHN_ADD_CREDENTIAL_TITLE: "Add Passkey", + PL_SEC_WEBAUTHN_ADD_CREDENTIAL_DESCRIPTION: "Add a new Passkey to your account", + }, +} as const; diff --git a/packages/profile-security-react/src/types.ts b/packages/profile-security-react/src/types.ts new file mode 100644 index 0000000..2f7d63a --- /dev/null +++ b/packages/profile-security-react/src/types.ts @@ -0,0 +1,21 @@ +import { MultiFactorAuthPreBuiltUI } from "supertokens-auth-react/recipe/multifactorauth/prebuiltui"; + +import { defaultTranslationsSecurity } from "./translations"; + +export type SuperTokensPluginProfileSecurityConfig = undefined; + +export type TranslationKeys = + | keyof (typeof defaultTranslationsSecurity)["en"] + | keyof (typeof MultiFactorAuthPreBuiltUI.languageTranslations)["en"]; + +export type ProfileDetails = Record; +export type AccountDetails = { + emails: string[]; + phoneNumbers: string[]; + connectedAccounts: ConnectedAccount[]; +}; + +export type ConnectedAccount = { + provider: string; + email: string; +}; diff --git a/packages/profile-security-react/tsconfig.json b/packages/profile-security-react/tsconfig.json new file mode 100644 index 0000000..9ce578d --- /dev/null +++ b/packages/profile-security-react/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@shared/tsconfig/react.json", + "compilerOptions": { + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist", + "noUnusedLocals": false, + "noImplicitAny": false + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.tsx", "src/**/*.spec.tsx", "src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/profile-security-react/vite.config.ts b/packages/profile-security-react/vite.config.ts new file mode 100644 index 0000000..aa5a193 --- /dev/null +++ b/packages/profile-security-react/vite.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import dts from "vite-plugin-dts"; +import peerDepsExternal from "rollup-plugin-peer-deps-external"; +import * as path from "path"; +import packageJson from "./package.json"; +import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; + +export default defineConfig(() => { + return { + root: __dirname, + plugins: [ + react(), + dts({ + entryRoot: "src", + tsconfigPath: path.join(__dirname, "tsconfig.json"), + }), + peerDepsExternal(), + cssInjectedByJsPlugin(), + ], + + build: { + outDir: "dist", + sourcemap: false, + emptyOutDir: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: "src/index.ts", + fileName: "index", + name: packageJson.name, + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ["es" as const, "cjs" as const], + }, + rollupOptions: { + cache: false, + }, + }, + }; +}); From 00b8ff502499c8e2c9c8f5f5805da5fb0631fbe1 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 8 Oct 2025 11:30:07 +0300 Subject: [PATCH 04/11] styling fixes --- .../src/components/list-card/index.ts | 1 + .../components/list-card/list-card.module.css | 52 ++++ .../src/components/list-card/list-card.tsx | 27 ++ .../change-password-section.tsx | 115 ++++++-- .../components/security-section/list-card.tsx | 31 +++ .../security-section/mfa-section.tsx | 96 ++++--- .../security-section.module.css | 252 ++++++++++++------ .../security-section/security-section.tsx | 28 +- .../security-section/set-password-section.tsx | 132 +++++++-- .../security-section/set-webauthn-section.tsx | 39 +-- .../security-section/third-party-section.tsx | 69 +++-- .../security-section/webauthn-section.tsx | 96 +++---- .../src/translations.ts | 36 ++- 13 files changed, 681 insertions(+), 293 deletions(-) create mode 100644 packages/profile-security-react/src/components/list-card/index.ts create mode 100644 packages/profile-security-react/src/components/list-card/list-card.module.css create mode 100644 packages/profile-security-react/src/components/list-card/list-card.tsx create mode 100644 packages/profile-security-react/src/components/security-section/list-card.tsx diff --git a/packages/profile-security-react/src/components/list-card/index.ts b/packages/profile-security-react/src/components/list-card/index.ts new file mode 100644 index 0000000..f404f63 --- /dev/null +++ b/packages/profile-security-react/src/components/list-card/index.ts @@ -0,0 +1 @@ +export * from "./list-card"; diff --git a/packages/profile-security-react/src/components/list-card/list-card.module.css b/packages/profile-security-react/src/components/list-card/list-card.module.css new file mode 100644 index 0000000..86b0a00 --- /dev/null +++ b/packages/profile-security-react/src/components/list-card/list-card.module.css @@ -0,0 +1,52 @@ +.supertokens-plugin-list-card { + --spacing: 10px; +} + +.supertokens-plugin-list-card h2 { + font-size: 14px; + line-height: 1.4; + font-weight: 600; +} + +.supertokens-plugin-list-card-container { + margin-left: calc(var(--spacing) * -1); + margin-right: calc(var(--spacing) * -1); + padding: 0 var(--spacing); + border-top: 1px solid var(--border-color); +} +.supertokens-plugin-list-card-container-no-title { + border-top: none; +} + +.supertokens-plugin-list-card-item { + border-bottom: 1px solid var(--border-color); + display: flex; + flex-direction: row; + align-items: center; + padding: 10px 0; +} +.supertokens-plugin-list-card-item:last-child { + border-bottom: none; +} +.supertokens-plugin-list-card-container-no-title .supertokens-plugin-list-card-item:first-child { + padding-top: 0; +} + +.supertokens-plugin-list-card-item-actions { + display: flex; + flex-direction: row; + gap: 10px; + margin-left: auto; +} + +.supertokens-plugin-list-card-footer { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + border-top: 1px solid var(--border-color); + margin: 0 calc(var(--spacing) * -1); + padding-left: var(--spacing); + padding-right: var(--spacing); + padding-top: var(--spacing); +} diff --git a/packages/profile-security-react/src/components/list-card/list-card.tsx b/packages/profile-security-react/src/components/list-card/list-card.tsx new file mode 100644 index 0000000..2d911e6 --- /dev/null +++ b/packages/profile-security-react/src/components/list-card/list-card.tsx @@ -0,0 +1,27 @@ +import { Card } from "@shared/ui"; +import classNames from "classnames/bind"; + +import style from "./list-card.module.css"; + +const cx = classNames.bind(style); + +export const ListCard = ({ title, items, FooterComponent }: { title?: string; items: any[]; FooterComponent: any }) => { + return ( + +
+ {items.map((item, index) => ( +
+ {} + + {item.Actions &&
{}
} +
+ ))} +
+ + {FooterComponent &&
{}
} +
+ ); +}; diff --git a/packages/profile-security-react/src/components/security-section/change-password-section.tsx b/packages/profile-security-react/src/components/security-section/change-password-section.tsx index 318407d..b586462 100644 --- a/packages/profile-security-react/src/components/security-section/change-password-section.tsx +++ b/packages/profile-security-react/src/components/security-section/change-password-section.tsx @@ -1,6 +1,6 @@ import { Button, PasswordInput, useToast, usePrettyAction } from "@shared/ui"; import classNames from "classnames/bind"; -import { useState } from "react"; +import { useState, useCallback, useEffect } from "react"; import { usePluginContext } from "../../plugin"; @@ -17,10 +17,25 @@ export const ChangePasswordSection = ({ }) => { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [confirmPasswordError, setConfirmPasswordError] = useState(""); + const [passwordInputVisible, setPasswordInputVisible] = useState(false); const { api, t } = usePluginContext(); const { addToast } = useToast(); + const handleShowPasswordInput = useCallback(() => { + setPasswordInputVisible(true); + }, []); + + const handleHidePasswordInput = useCallback(() => { + setPasswordInputVisible(false); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setConfirmPasswordError(""); + }, []); + const changePassword = usePrettyAction( async () => { const res = await api.changePassword(currentPassword, newPassword); @@ -30,6 +45,9 @@ export const ChangePasswordSection = ({ setCurrentPassword(""); setNewPassword(""); + setConfirmPassword(""); + setConfirmPasswordError(""); + setPasswordInputVisible(false); }, [currentPassword, newPassword, addToast], { @@ -39,29 +57,82 @@ export const ChangePasswordSection = ({ }, ); + useEffect(() => { + if (!newPassword || !confirmPassword) { + setConfirmPasswordError(""); + return; + } + + if (newPassword !== confirmPassword) { + setConfirmPasswordError(t("PL_SEC_SET_PASSWORD_CONFIRM_PASSWORD_ERROR")); + } else { + setConfirmPasswordError(""); + } + }, [newPassword, confirmPassword, t]); + return ( -
- -
- -
-
- + +
+ {t("PL_SEC_CHANGE_PASSWORD_LABEL")} + + {passwordInputVisible ? ( + <> + +
+ +
+ + + ) : ( + + )} +
+ + {passwordInputVisible && ( +
+ + +
+ )} ); }; diff --git a/packages/profile-security-react/src/components/security-section/list-card.tsx b/packages/profile-security-react/src/components/security-section/list-card.tsx new file mode 100644 index 0000000..5bc8bdb --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/list-card.tsx @@ -0,0 +1,31 @@ +import { Button, Card, SelectInput } from "@shared/ui"; +import classNames from "classnames/bind"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); + +export const ListCard = ({ title, items, FooterComponent }: { title?: string; items: any[]; FooterComponent: any }) => { + return ( + +
+ {items.map((item, index) => ( +
+ {} + + {item.Actions && ( +
{}
+ )} +
+ ))} +
+ + {FooterComponent && ( +
{}
+ )} +
+ ); +}; diff --git a/packages/profile-security-react/src/components/security-section/mfa-section.tsx b/packages/profile-security-react/src/components/security-section/mfa-section.tsx index 1742f1b..1c62b09 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-section.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-section.tsx @@ -144,58 +144,56 @@ export const MfaSection = ({ } return ( -
+
{secondaryFactors.map((factor) => ( -
-
- - {t(factor.name as TranslationKeys)} - - - {factor.setup && ( - - {t("PL_SEC_MFA_SETUP")} - - )} - - {factor.setup && ( - toggleSecondaryFactor(factor.id)} - /> - )} - - {!factor.setup && factorBeingSetup !== factor.id && ( - - )} - - {!factor.setup && factorBeingSetup === factor.id && ( - - )} +
+
+
+ + {t(factor.name as TranslationKeys)} + + + + {t(factor.description as TranslationKeys)} + +
+ +
+ {factor.setup && ( + toggleSecondaryFactor(factor.id)} + /> + )} + + {!factor.setup && factorBeingSetup !== factor.id && ( + + )} + + {!factor.setup && factorBeingSetup === factor.id && ( + + )} +
- - {t(factor.description as TranslationKeys)} - - {factor.ManageComponent && factorBeingSetup === factor.id && ( )} diff --git a/packages/profile-security-react/src/components/security-section/security-section.module.css b/packages/profile-security-react/src/components/security-section/security-section.module.css index f8bac36..646cea4 100644 --- a/packages/profile-security-react/src/components/security-section/security-section.module.css +++ b/packages/profile-security-react/src/components/security-section/security-section.module.css @@ -1,150 +1,240 @@ -.plugin-profile-security-section { +.supertokens-plugin-profile-security-section { + --border-color: var(--wa-color-neutral-30); + --title-color: var(--wa-color-neutral-95); + --title-font-size: round(calc(1.25rem * var(--wa-font-size-scale)), 1px); + --spacing: 30px; + display: flex; flex-direction: column; - max-width: 800px; - margin: 0 auto; - padding: 0; - font-family: var(--plugin-font-family); - color: #333; width: 100%; } -.plugin-profile-security-header { +.supertokens-plugin-profile-security-header { position: relative; - padding-bottom: 24px; - margin-bottom: 24px; - border-bottom: 1px solid #eee; + margin-bottom: var(--spacing); +} + +.supertokens-plugin-profile-security-header h3 { + color: var(--title-color); + font-size: var(--title-font-size); + line-height: 1.4; + margin-bottom: 8px; } -.plugin-profile-security-header h3 { - margin-bottom: 12px; +.supertokens-plugin-profile-security-header p { + margin: 0; + font-size: var(--wa-font-size-s); + line-height: 1.4; } -.plugin-profile-security-group { +.supertokens-plugin-profile-security-group { display: flex; flex-direction: column; - gap: var(--plugin-spacing-xl); + border-bottom: 1px solid var(--border-color); + padding: 30px 0; +} +.supertokens-plugin-profile-security-group:first-child { + border-top: 1px solid var(--border-color); +} +.supertokens-plugin-profile-security-group:last-child { + border-bottom: none; + padding-bottom: 0; +} - padding-bottom: 24px; - margin-bottom: 20px; +.supertokens-plugin-profile-security-group h4 { + color: var(--title-color); + font-size: var(--wa-font-size-l); + line-height: 1.4; + margin-bottom: 30px; + margin-top: 0; +} - border-bottom: 1px solid #eee; +.supertokens-plugin-profile-security-edit-form { + display: flex; + flex-direction: column; + gap: 24px; } -.plugin-profile-security-group:last-child { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; +.supertokens-plugin-profile-security-edit-form wa-input.st-input::part(label) { + display: none; } -.plugin-profile-security-group h3 { - font-size: 16px; - font-weight: 600; - margin: 0 0 8px; +.supertokens-plugin-profile-security-item { + display: flex; + flex-direction: row; + gap: 0; + align-items: start; } -.plugin-profile-security-group h4 { - color: var(--plugin-text-secondary); - font-size: 14px; - font-weight: 600; - margin: 0 0 4px; +.supertokens-plugin-profile-security-label { + font-weight: var(--wa-font-weight-semibold); + color: var(--wa-color-neutral-85); + font-size: var(--wa-font-size-m); + line-height: 1.5; + width: 260px; } -.plugin-profile-security-link { - color: #07c; - text-decoration: none; +.supertokens-plugin-profile-security-required { + color: var(--wa-color-danger-70); +} - &:hover { - text-decoration: underline; - } +.supertokens-plugin-profile-security-value { + flex: 1; + color: var(--wa-color-neutral-95); + font-size: var(--wa-font-size-m); + line-height: 1.5; + font-weight: var(--wa-font-weight-normal); +} + +.supertokens-plugin-profile-security-form-actions { + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 10px; +} + +.supertokens-plugin-profile-security-no-linked-accounts { + margin-bottom: 13px; + color: var(--wa-color-neutral-95); } -.plugin-profile-security-linked-account { +/* Linked accounts */ +.supertokens-plugin-profile-security-linked-accounts { + display: flex; + flex-direction: column; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 10px; +} + +.supertokens-plugin-profile-security-link-account-buttons { + display: flex; + flex-direction: column; + gap: 10px; +} + +.supertokens-plugin-profile-security-linked-account { display: flex; align-items: center; gap: 16px; } -.plugin-profile-security-linked-account-provider { +.supertokens-plugin-profile-security-linked-account-tag { + font-size: 20px; +} + +.supertokens-plugin-profile-security-linked-account-name { display: flex; align-items: center; - gap: var(--plugin-spacing-md); + gap: 2px; +} +.supertokens-plugin-profile-security-linked-account-name, +.supertokens-plugin-profile-security-linked-account-email { font-weight: 500; + font-size: 14px; + line-height: 1.4; } -.plugin-profile-security-linked-account-provider-logo { - margin-right: var(--plugin-spacing-md); +.supertokens-plugin-profile-security-linked-account-unlink-button { + margin-left: auto; +} + +/* Link accounts */ +.supertokens-plugin-profile-security-link-account-button::part(label) { + align-items: center; + display: flex; + flex-direction: row; + gap: 8px; +} +.supertokens-plugin-profile-security-link-account-button-logo { line-height: 0; } -.plugin-profile-security-linked-account-unlink-button { +.supertokens-plugin-profile-security-passkey-date { margin-left: auto; } -.plugin-profile-security-link-account-buttons { - display: flex; - flex-direction: column; - gap: 8px; +.supertokens-plugin-profile-security-passkey-email-select { + width: 100%; } -.plugin-profile-security-second-factor { +.supertokens-plugin-profile-security-passkey-email-select-label { + font-size: 14px; + line-height: 1.4; + font-weight: 500; + word-break: keep-all; +} + +/* MFA */ +.supertokens-plugin-profile-security-second-factor { display: flex; flex-direction: column; - gap: var(--plugin-spacing-2xl); + margin-bottom: 10px; } -.plugin-profile-security-second-factor-method-header { +.supertokens-plugin-profile-security-second-factor-method { + padding: 20px 0; + border-bottom: 1px solid var(--border-color); +} +.supertokens-plugin-profile-security-second-factor-method:first-child { + padding-top: 0; +} +.supertokens-plugin-profile-security-second-factor-method:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.supertokens-plugin-profile-security-second-factor-method-header { display: flex; + flex-direction: row; align-items: center; - gap: 8px; } -.plugin-profile-security-second-factor-method-action { - margin-left: auto; +.supertokens-plugin-profile-security-second-factor-method-header-content { + display: flex; + flex-direction: column; + gap: 4px; } - -.plugin-profile-security-second-factor-method-label { - font-weight: var(--plugin-font-weight-medium); - font-size: var(--plugin-font-size-md); - line-height: var(--plugin-line-height-normal); - color: var(--plugin-text-secondary); +.supertokens-plugin-profile-security-second-factor-method-label { + font-weight: 500; + font-size: 14px; + line-height: 1.4; + color: var(--title-color); } -.plugin-profile-security-second-factor-method-description { - color: var(--plugin-text-secondary); - font-size: var(--plugin-font-size-sm); - line-height: var(--plugin-line-height-tight); +.supertokens-plugin-profile-security-second-factor-method-description { + font-size: 14px; + line-height: 1.4; } -.plugin-profile-security-second-factor-method-button { +.supertokens-plugin-profile-security-second-factor-method-header-actions { margin-left: auto; } -.plugin-profile-security-manage { - margin-top: var(--plugin-spacing-xl); - padding-left: var(--plugin-spacing-xl); - border-left: 2px solid var(--plugin-color-primary); +/* ------------------------------------------------------------ */ + +.plugin-profile-security-link { + color: #07c; + text-decoration: none; + + &:hover { + text-decoration: underline; + } } -.plugin-profile-security-manage-item { - display: flex; - flex-direction: row; - align-items: baseline; - gap: var(--plugin-spacing-md); - margin-bottom: var(--plugin-spacing-md); +.plugin-profile-security-second-factor-method-action { + margin-left: auto; } -.plugin-profile-security-manage-item-actions { - display: flex; - flex-direction: row; - gap: var(--plugin-spacing-md); +.plugin-profile-security-second-factor-method-button { margin-left: auto; } -.plugin-profile-security-manage-container { - border-top: 1px solid var(--plugin-border-secondary); - padding-top: var(--plugin-spacing-lg); - margin-top: var(--plugin-spacing-lg); +.plugin-profile-security-manage { + margin-top: var(--plugin-spacing-xl); + padding-left: var(--plugin-spacing-xl); + border-left: 2px solid var(--plugin-color-primary); } .plugin-profile-security-item-description { diff --git a/packages/profile-security-react/src/components/security-section/security-section.tsx b/packages/profile-security-react/src/components/security-section/security-section.tsx index d1884d6..81f3f14 100644 --- a/packages/profile-security-react/src/components/security-section/security-section.tsx +++ b/packages/profile-security-react/src/components/security-section/security-section.tsx @@ -83,23 +83,23 @@ export const SecurityDetailsSection = () => { }, [isLoaded, loadConfig, loadUserInfo]); return ( -
-
+
+

{t("PL_SEC_HEADER_TITLE")}

{t("PL_SEC_HEADER_DESCRIPTION")}

{hasPasswordRecipe && hasPasswordLoginMethod && ( -
-

{t("PL_SEC_CHANGE_PASSWORD_TITLE")}

+
+

{t("PL_SEC_CHANGE_PASSWORD_TITLE")}

)} {config?.enableSettingPassword && hasPasswordRecipe && !hasPasswordLoginMethod && ( -
-

{t("PL_SEC_SET_PASSWORD_TITLE")}

+
+

{t("PL_SEC_SET_PASSWORD_TITLE")}

{ )} {hasWebauthnRecipe && !hasWebauthnLoginMethod && ( -
-

{t("PL_SEC_SET_WEBAUTHN_TITLE")}

+
+

{t("PL_SEC_SET_WEBAUTHN_TITLE")}

{ )} {hasWebauthnRecipe && hasWebauthnLoginMethod && ( -
-

{t("PL_SEC_WEBAUTHN_TITLE")}

+
+

{t("PL_SEC_WEBAUTHN_TITLE")}

)} {config?.enableThirdPartyLinkning && hasThirdpartyRecipe && ( -
-

{t("PL_SEC_TP_TITLE")}

+
+

{t("PL_SEC_TP_TITLE")}

{ )} {config?.enableMfaConfiguration && hasMultiFactorAuthRecipe && ( -
-

{t("PL_SEC_MFA_TITLE")}

+
+

{t("PL_SEC_MFA_TITLE")}

)} diff --git a/packages/profile-security-react/src/components/security-section/set-password-section.tsx b/packages/profile-security-react/src/components/security-section/set-password-section.tsx index b9165c0..3e1cc86 100644 --- a/packages/profile-security-react/src/components/security-section/set-password-section.tsx +++ b/packages/profile-security-react/src/components/security-section/set-password-section.tsx @@ -1,6 +1,6 @@ import { Button, SelectInput, PasswordInput, useToast, usePrettyAction } from "@shared/ui"; import classNames from "classnames/bind"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { User } from "supertokens-web-js/types"; import { usePluginContext } from "../../plugin"; @@ -22,10 +22,24 @@ export const SetPasswordSection = ({ }) => { const [passwordSetEmail, setPasswordSetEmail] = useState(""); const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [confirmPasswordError, setConfirmPasswordError] = useState(""); + const [passwordInputVisible, setPasswordInputVisible] = useState(false); const { api, t } = usePluginContext(); const { addToast } = useToast(); + const handleShowPasswordInput = useCallback(() => { + setPasswordInputVisible(true); + }, []); + + const handleHidePasswordInput = useCallback(() => { + setPasswordInputVisible(false); + setNewPassword(""); + setConfirmPassword(""); + setConfirmPasswordError(""); + }, []); + const setPassword = usePrettyAction( async () => { const res = await api.setPassword({ @@ -47,6 +61,19 @@ export const SetPasswordSection = ({ }, ); + useEffect(() => { + if (!newPassword || !confirmPassword) { + setConfirmPasswordError(""); + return; + } + + if (newPassword !== confirmPassword) { + setConfirmPasswordError(t("PL_SEC_SET_PASSWORD_CONFIRM_PASSWORD_ERROR")); + } else { + setConfirmPasswordError(""); + } + }, [newPassword, confirmPassword, t]); + useEffect(() => { if (!user) { return; @@ -62,34 +89,83 @@ export const SetPasswordSection = ({ } return ( -
- { - if (!value) { - return; - } - setPasswordSetEmail(value as string); - }} - options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} - disabled={(user?.emails.length ?? 0) <= 1} - /> -
- -
-
- + +
+ + {t("PL_SEC_SET_PASSWORD_SELECT_EMAIL_LABEL")} + + + + { + if (!value) { + return; + } + setPasswordSetEmail(value as string); + }} + options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} + disabled={(user?.emails.length ?? 0) <= 1} + /> + +
+ +
+ + {t("PL_SEC_SET_PASSWORD_PASSWORD_LABEL")} + + + + {passwordInputVisible ? ( + <> + +
+ + + ) : ( + + )} +
+ + {passwordInputVisible && ( +
+ + +
+ )} ); }; diff --git a/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx b/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx index 57dcad6..4abf08f 100644 --- a/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx +++ b/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx @@ -55,22 +55,29 @@ export const SetWebAuthnSection = ({ } return ( -
- { - if (!value) { - return; - } - setWebauthnSetEmail(value as string); - }} - options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} - disabled={(user?.emails.length ?? 0) <= 1} - /> -
-
+ +
+ + {t("PL_SEC_SET_WEBAUTHN_SELECT_EMAIL_LABEL")} + + + + { + if (!value) { + return; + } + setWebauthnSetEmail(value as string); + }} + options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} + disabled={(user?.emails.length ?? 0) <= 1} + /> + +
+ +
diff --git a/packages/profile-security-react/src/components/security-section/third-party-section.tsx b/packages/profile-security-react/src/components/security-section/third-party-section.tsx index ebd0633..bbc27d0 100644 --- a/packages/profile-security-react/src/components/security-section/third-party-section.tsx +++ b/packages/profile-security-react/src/components/security-section/third-party-section.tsx @@ -1,4 +1,4 @@ -import { Button, usePrettyAction, useToast } from "@shared/ui"; +import { Button, Callout, Tag, usePrettyAction, useToast } from "@shared/ui"; import classNames from "classnames/bind"; import { useMemo } from "react"; import { @@ -137,42 +137,63 @@ export const ThirdPartySection = ({ return ( <> {connectedAccounts.length === 0 && ( - {t("PL_SEC_TP_NO_LINKED_ACCOUNTS")} + + {t("PL_SEC_TP_NO_LINKED_ACCOUNTS")} + )} - {connectedAccounts.map((account, index) => ( -
- - {thirdPartyIdToProviderMap[account.providerId as keyof typeof thirdPartyIdToProviderMap].getLogo()} - {thirdPartyIdToProviderMap[account.providerId as keyof typeof thirdPartyIdToProviderMap].name} - - {account.email} - + + {connectedAccounts.length > 0 && ( +
+ {connectedAccounts.map((account, index) => ( +
+ + + {thirdPartyIdToProviderMap[account.providerId as keyof typeof thirdPartyIdToProviderMap].getLogo()} + {thirdPartyIdToProviderMap[account.providerId as keyof typeof thirdPartyIdToProviderMap].name} + + + {account.email} + +
+ ))}
- ))} + )} {availableSignUpProviders.length > 0 && ( -
+
{availableSignUpProviders.map((provider) => ( ))}
diff --git a/packages/profile-security-react/src/components/security-section/webauthn-section.tsx b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx index 4e0da22..42db58d 100644 --- a/packages/profile-security-react/src/components/security-section/webauthn-section.tsx +++ b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx @@ -1,4 +1,4 @@ -import { Button, SelectInput, usePrettyAction } from "@shared/ui"; +import { Button, Card, SelectInput, usePrettyAction } from "@shared/ui"; import classNames from "classnames/bind"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -9,6 +9,7 @@ import { import { User } from "supertokens-web-js/types"; import { usePluginContext } from "../../plugin"; +import { ListCard } from "../list-card"; import style from "./security-section.module.css"; @@ -130,57 +131,62 @@ export const WebauthnSection = ({ } return ( -
- {credentials.map((credential) => ( -
- - {webauthnEmail} - {t("PL_SEC_DOT_SEPARATOR")} - {new Date(credential.createdAt).toLocaleString()} - - -
+
+ ({ + Content: () => ( + <> + {webauthnEmail} + + {new Date(credential.createdAt).toLocaleString()} + + + ), + Actions: () => ( -
-
- ))} - -
-

{t("PL_SEC_WEBAUTHN_ADD_CREDENTIAL_TITLE")}

- -

- {t("PL_SEC_WEBAUTHN_ADD_CREDENTIAL_DESCRIPTION")} -

-
- - { - if (!value) { - return; - } - setWebauthnEmail(value as string); - }} - options={webAuthnEmails.map((email) => ({ label: email, value: email })) ?? []} - disabled={webAuthnEmails.length <= 1} - /> -
-
- -
-
+ ), + }))} + FooterComponent={() => ( + <> +
+ {t("PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL")} +
+ + { + if (!value) { + return; + } + setWebauthnEmail(value as string); + }} + options={webAuthnEmails.map((email) => ({ label: email, value: email })) ?? []} + disabled={webAuthnEmails.length <= 1} + /> + + + + )} + />
); }; diff --git a/packages/profile-security-react/src/translations.ts b/packages/profile-security-react/src/translations.ts index 951cef0..87623c7 100644 --- a/packages/profile-security-react/src/translations.ts +++ b/packages/profile-security-react/src/translations.ts @@ -5,19 +5,30 @@ export const defaultTranslationsSecurity = { PL_SEC_DOT_SEPARATOR: "·", PL_SEC_SET_PASSWORD_TITLE: "Set password", - PL_SEC_SET_PASSWORD_BUTTON: "Set password", + PL_SEC_SET_PASSWORD_SHOW_BUTTON: "Set Password", + PL_SEC_SET_PASSWORD_BUTTON: "Save", + PL_SEC_SET_PASSWORD_CANCEL_BUTTON: "Cancel", PL_SEC_SET_PASSWORD_PASSWORD_LABEL: "Password", + PL_SEC_SET_PASSWORD_NEW_PASSWORD_LABEL: "New Password", + PL_SEC_SET_PASSWORD_CONFIRM_PASSWORD_LABEL: "Confirm New Password", PL_SEC_SET_PASSWORD_PASSWORD_PLACEHOLDER: "Enter your password", - PL_SEC_SET_PASSWORD_SELECT_EMAIL_LABEL: "Select email", + PL_SEC_SET_PASSWORD_CONFIRM_PASSWORD_PLACEHOLDER: "Enter your password again", + PL_SEC_SET_PASSWORD_SELECT_EMAIL_LABEL: "Select Email", PL_SEC_SET_PASSWORD_ERROR_MESSAGE: "Failed to set password", PL_SEC_SET_PASSWORD_SUCCESS_MESSAGE: "Password set successfully", + PL_SEC_SET_PASSWORD_CONFIRM_PASSWORD_ERROR: "Passwords do not match", - PL_SEC_CURRENT_PASSWORD_LABEL: "Current password", + PL_SEC_CHANGE_PASSWORD_LABEL: "Password", + PL_SEC_CURRENT_PASSWORD_LABEL: "Current Password", PL_SEC_CURRENT_PASSWORD_PLACEHOLDER: "Enter your current password", - PL_SEC_NEW_PASSWORD_LABEL: "New password", + PL_SEC_NEW_PASSWORD_LABEL: "New Password", PL_SEC_NEW_PASSWORD_PLACEHOLDER: "Enter your new password", - PL_SEC_CHANGE_PASSWORD_TITLE: "Change password", - PL_SEC_CHANGE_PASSWORD_BUTTON: "Change password", + PL_SEC_CHANGE_PASSWORD_CONFIRM_PASSWORD_LABEL: "Confirm New Password", + PL_SEC_CHANGE_PASSWORD_CONFIRM_PASSWORD_PLACEHOLDER: "Enter your new password again", + PL_SEC_CHANGE_PASSWORD_TITLE: "Change Password", + PL_SEC_CHANGE_PASSWORD_BUTTON: "Save", + PL_SEC_CHANGE_PASSWORD_CANCEL_BUTTON: "Cancel", + PL_SEC_CHANGE_PASSWORD_SHOW_BUTTON: "Change Password", PL_SEC_CONFIRM_NEW_PASSWORD: "Confirm new password", PL_SEC_CHANGE_PASSWORD_SUCCESS_CHANGE: "Password changed successfully", @@ -26,14 +37,13 @@ export const defaultTranslationsSecurity = { PL_SEC_TP_LINK_ACCOUNT_BUTTON: "Link", PL_SEC_TP_UNLINK_ACCOUNT_BUTTON: "Unlink", PL_SEC_TP_TITLE: "Linked accounts", - PL_SEC_TP_NO_LINKED_ACCOUNTS: "No linked accounts", + PL_SEC_TP_NO_LINKED_ACCOUNTS: "At this moment, there are no accounts linked to your user profile.", PL_SEC_TP_SUCCESS_LINK_ACCOUNT: "{provider} account linked successfully", PL_SEC_TP_SUCCESS_UNLINK_ACCOUNT: "{provider} account unlinked successfully", PL_SEC_TP_ERROR_LINK_ACCOUNT: "Failed to link {provider} account. Please make sure it is not already linked to another account.", PL_SEC_TP_ERROR_UNLINK_ACCOUNT: "Failed to unlink account", - PL_SEC_MFA_SETUP: "Setup", PL_SEC_MFA_SETUP_BUTTON: "Setup", PL_SEC_MFA_SETUP_CANCEL_BUTTON: "Cancel", PL_SEC_MFA_TITLE: "Two-factor authentication", @@ -118,21 +128,19 @@ export const defaultTranslationsSecurity = { PL_SEC_MFA_SETUP_EMAIL_ERROR_VERIFY: "Failed to verify code", PL_SEC_MFA_SETUP_EMAIL_SUCCESS_VERIFY: "Email verified successfully. You can now enable this factor.", - PL_SEC_SET_WEBAUTHN_TITLE: "Passkeys", + PL_SEC_SET_WEBAUTHN_TITLE: "Passkey", PL_SEC_SET_WEBAUTHN_BUTTON: "Add Passkey", - PL_SEC_SET_WEBAUTHN_SELECT_EMAIL_LABEL: "Select email", + PL_SEC_SET_WEBAUTHN_SELECT_EMAIL_LABEL: "Select Email", PL_SEC_SET_WEBAUTHN_SUCCESS_SET_CREDENTIAL: "Passkey added successfully", PL_SEC_SET_WEBAUTHN_ERROR_SET_CREDENTIAL: "Failed to add Passkey", - PL_SEC_WEBAUTHN_TITLE: "Passkeys", + PL_SEC_WEBAUTHN_TITLE: "Passkey", PL_SEC_WEBAUTHN_ERROR_ADD_CREDENTIAL: "Failed to add Passkey", PL_SEC_WEBAUTHN_SUCCESS_ADD_CREDENTIAL: "Passkey added successfully", PL_SEC_WEBAUTHN_ERROR_REMOVE_CREDENTIAL: "Failed to remove Passkey", PL_SEC_WEBAUTHN_ERROR_LOAD_CREDENTIALS: "Failed to load Passkeys", - PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL: "Select email", + PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL: "Select\u00A0Email", PL_SEC_WEBAUTHN_REMOVE_BUTTON: "Remove", PL_SEC_WEBAUTHN_ADD_CREDENTIAL_BUTTON: "Add Passkey", - PL_SEC_WEBAUTHN_ADD_CREDENTIAL_TITLE: "Add Passkey", - PL_SEC_WEBAUTHN_ADD_CREDENTIAL_DESCRIPTION: "Add a new Passkey to your account", }, } as const; From 3fa92395fb4c357d0a7c54ddde09fed8d4ffa124 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Sun, 12 Oct 2025 23:17:42 +0300 Subject: [PATCH 05/11] cleanup --- .../form-actions/form-actions.module.css | 6 + .../components/form-actions/form-actions.tsx | 9 + .../src/components/form-actions/index.ts | 1 + .../components/form-item/form-item.module.css | 26 + .../src/components/form-item/form-item.tsx | 17 + .../src/components/form-item/index.ts | 1 + .../components/list-card/list-card.module.css | 2 + .../src/components/list-card/list-card.tsx | 95 +++- .../change-password-section.tsx | 95 ++-- .../components/security-section/list-card.tsx | 31 -- .../security-section/mfa-factor-email-otp.tsx | 153 +++--- .../security-section/mfa-factor-phone-otp.tsx | 171 +++--- .../security-section/mfa-factor-totp.tsx | 510 ++++++------------ .../security-section/mfa-section.tsx | 6 +- .../security-section.module.css | 97 +--- .../security-section/set-password-section.tsx | 116 ++-- .../security-section/set-webauthn-section.tsx | 42 +- .../security-section/webauthn-section.tsx | 109 ++-- .../src/translations.ts | 40 +- shared/ui/src/theme/wa/components/input.css | 3 + 20 files changed, 722 insertions(+), 808 deletions(-) create mode 100644 packages/profile-security-react/src/components/form-actions/form-actions.module.css create mode 100644 packages/profile-security-react/src/components/form-actions/form-actions.tsx create mode 100644 packages/profile-security-react/src/components/form-actions/index.ts create mode 100644 packages/profile-security-react/src/components/form-item/form-item.module.css create mode 100644 packages/profile-security-react/src/components/form-item/form-item.tsx create mode 100644 packages/profile-security-react/src/components/form-item/index.ts delete mode 100644 packages/profile-security-react/src/components/security-section/list-card.tsx diff --git a/packages/profile-security-react/src/components/form-actions/form-actions.module.css b/packages/profile-security-react/src/components/form-actions/form-actions.module.css new file mode 100644 index 0000000..314bb22 --- /dev/null +++ b/packages/profile-security-react/src/components/form-actions/form-actions.module.css @@ -0,0 +1,6 @@ +.supertokens-plugin-profile-security-form-actions { + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 10px; +} diff --git a/packages/profile-security-react/src/components/form-actions/form-actions.tsx b/packages/profile-security-react/src/components/form-actions/form-actions.tsx new file mode 100644 index 0000000..bc80db7 --- /dev/null +++ b/packages/profile-security-react/src/components/form-actions/form-actions.tsx @@ -0,0 +1,9 @@ +import classNames from "classnames/bind"; + +import style from "./form-actions.module.css"; + +const cx = classNames.bind(style); + +export const FormActions = ({ children }: { children: any }) => { + return
{children}
; +}; diff --git a/packages/profile-security-react/src/components/form-actions/index.ts b/packages/profile-security-react/src/components/form-actions/index.ts new file mode 100644 index 0000000..66b4c06 --- /dev/null +++ b/packages/profile-security-react/src/components/form-actions/index.ts @@ -0,0 +1 @@ +export * from "./form-actions"; diff --git a/packages/profile-security-react/src/components/form-item/form-item.module.css b/packages/profile-security-react/src/components/form-item/form-item.module.css new file mode 100644 index 0000000..d895d77 --- /dev/null +++ b/packages/profile-security-react/src/components/form-item/form-item.module.css @@ -0,0 +1,26 @@ +.supertokens-plugin-profile-security-item { + display: flex; + flex-direction: row; + gap: 0; + align-items: center; +} + +.supertokens-plugin-profile-security-label { + font-weight: var(--wa-font-weight-semibold); + color: var(--wa-color-neutral-85); + font-size: var(--wa-font-size-m); + line-height: 1.5; + width: 260px; +} + +.supertokens-plugin-profile-security-required { + color: var(--wa-color-danger-70); +} + +.supertokens-plugin-profile-security-value { + flex: 1; + color: var(--wa-color-neutral-95); + font-size: var(--wa-font-size-m); + line-height: 1.5; + font-weight: var(--wa-font-weight-normal); +} diff --git a/packages/profile-security-react/src/components/form-item/form-item.tsx b/packages/profile-security-react/src/components/form-item/form-item.tsx new file mode 100644 index 0000000..d9e8d72 --- /dev/null +++ b/packages/profile-security-react/src/components/form-item/form-item.tsx @@ -0,0 +1,17 @@ +import classNames from "classnames/bind"; + +import style from "./form-item.module.css"; + +const cx = classNames.bind(style); + +export const FormRow = ({ label, children, required }: { label: string; children: any; required?: boolean }) => { + return ( +
+ + {label} + {required && *} + + {children} +
+ ); +}; diff --git a/packages/profile-security-react/src/components/form-item/index.ts b/packages/profile-security-react/src/components/form-item/index.ts new file mode 100644 index 0000000..d969ab2 --- /dev/null +++ b/packages/profile-security-react/src/components/form-item/index.ts @@ -0,0 +1 @@ +export * from "./form-item"; diff --git a/packages/profile-security-react/src/components/list-card/list-card.module.css b/packages/profile-security-react/src/components/list-card/list-card.module.css index 86b0a00..130f9a5 100644 --- a/packages/profile-security-react/src/components/list-card/list-card.module.css +++ b/packages/profile-security-react/src/components/list-card/list-card.module.css @@ -24,6 +24,7 @@ flex-direction: row; align-items: center; padding: 10px 0; + gap: 10px; } .supertokens-plugin-list-card-item:last-child { border-bottom: none; @@ -41,6 +42,7 @@ .supertokens-plugin-list-card-footer { display: flex; + flex-wrap: wrap; flex-direction: row; align-items: center; gap: 10px; diff --git a/packages/profile-security-react/src/components/list-card/list-card.tsx b/packages/profile-security-react/src/components/list-card/list-card.tsx index 2d911e6..3daee5f 100644 --- a/packages/profile-security-react/src/components/list-card/list-card.tsx +++ b/packages/profile-security-react/src/components/list-card/list-card.tsx @@ -1,27 +1,92 @@ import { Card } from "@shared/ui"; import classNames from "classnames/bind"; +import { useMemo } from "react"; import style from "./list-card.module.css"; const cx = classNames.bind(style); -export const ListCard = ({ title, items, FooterComponent }: { title?: string; items: any[]; FooterComponent: any }) => { +export const ListCard = ({ title, children }: { title?: string; children: any }) => { + const footerChild = useMemo(() => { + if (!children) { + return undefined; + } + + if (children?.type === ListCardFooter) { + return children; + } + + if (Array.isArray(children)) { + return children.find((child) => child?.type === ListCardFooter); + } + + return undefined; + }, [children]); + + const restChildren = useMemo(() => { + if (Array.isArray(children)) { + return children.filter((child) => child?.type !== ListCardFooter); + } + + if (children?.type !== ListCardFooter) { + return children; + } + + return []; + }, [children]); + return ( -
- {items.map((item, index) => ( -
- {} - - {item.Actions &&
{}
} -
- ))} -
- - {FooterComponent &&
{}
} + {Boolean(restChildren.length) && ( +
+ {restChildren} +
+ )} + + {footerChild}
); }; + +export function ListCardFooter({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export const ListCardItemActions = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +export const ListCardItem = ({ children }: { children: any }) => { + const actionsChild = useMemo(() => { + if (children?.type === ListCardItemActions) { + return children; + } + + if (Array.isArray(children)) { + return children.find((child) => child?.type === ListCardItemActions); + } + + return undefined; + }, [children]); + + const contentChildren = useMemo(() => { + if (Array.isArray(children)) { + return children.filter((child) => child?.type !== ListCardItemActions); + } + if (children?.type !== ListCardItemActions) { + return children; + } + + return undefined; + }, [children]); + + return ( +
+ {contentChildren} + {actionsChild} +
+ ); +}; diff --git a/packages/profile-security-react/src/components/security-section/change-password-section.tsx b/packages/profile-security-react/src/components/security-section/change-password-section.tsx index b586462..79cfac5 100644 --- a/packages/profile-security-react/src/components/security-section/change-password-section.tsx +++ b/packages/profile-security-react/src/components/security-section/change-password-section.tsx @@ -3,6 +3,8 @@ import classNames from "classnames/bind"; import { useState, useCallback, useEffect } from "react"; import { usePluginContext } from "../../plugin"; +import { FormActions } from "../form-actions"; +import { FormRow } from "../form-item"; import style from "./security-section.module.css"; @@ -71,55 +73,52 @@ export const ChangePasswordSection = ({ }, [newPassword, confirmPassword, t]); return ( - -
- {t("PL_SEC_CHANGE_PASSWORD_LABEL")} - - {passwordInputVisible ? ( - <> - -
- -
- - - ) : ( - - )} -
-
+ + + {passwordInputVisible ? ( + <> + +
+ +
+ + + ) : ( + + )} +
{passwordInputVisible && ( -
+ -
+ )} ); diff --git a/packages/profile-security-react/src/components/security-section/list-card.tsx b/packages/profile-security-react/src/components/security-section/list-card.tsx deleted file mode 100644 index 5bc8bdb..0000000 --- a/packages/profile-security-react/src/components/security-section/list-card.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Button, Card, SelectInput } from "@shared/ui"; -import classNames from "classnames/bind"; - -import style from "./security-section.module.css"; - -const cx = classNames.bind(style); - -export const ListCard = ({ title, items, FooterComponent }: { title?: string; items: any[]; FooterComponent: any }) => { - return ( - -
- {items.map((item, index) => ( -
- {} - - {item.Actions && ( -
{}
- )} -
- ))} -
- - {FooterComponent && ( -
{}
- )} -
- ); -}; diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx index b56f22b..7d00b49 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx @@ -5,6 +5,8 @@ import { consumeCode, createCode } from "supertokens-auth-react/recipe/passwordl import { User } from "supertokens-web-js/types"; import { usePluginContext } from "../../plugin"; +import { FormActions } from "../form-actions"; +import { FormRow } from "../form-item"; import style from "./security-section.module.css"; @@ -55,26 +57,36 @@ export const MfaFactorEmailOtpConfig = ({ user, onSuccess }: { user: User; onSuc ); if (!loginMethod) { - return
{t("PL_SEC_MFA_ERROR_WRONG_CONFIGURATION")}
; + return ( +
+ {t("PL_SEC_MFA_ERROR_WRONG_CONFIGURATION")} +
+ ); } return ( -
- { - if (!value) { - return; - } - setSelectedEmail(value as string); - }} - options={user.emails.map((email) => ({ label: email, value: email }))} - /> - +
+ + { + if (!value) { + return; + } + setSelectedEmail(value as string); + }} + options={user.emails.map((email) => ({ label: email, value: email }))} + /> + + +
+ + + +
); }; @@ -145,63 +157,74 @@ export const MfaFactorEmailOtpSetup = ({ user, onSuccess }: { user: User; onSucc ); return ( -
- {!emailSent ? ( +
+ {!emailSent && ( <> - {availableEmails.length > 0 ? ( - { - if (!value) { - return; - } - setEmail(value); - }} - options={availableEmails} - /> - ) : ( + + {availableEmails.length > 0 ? ( + { + if (!value) { + return; + } + setEmail(value); + }} + options={availableEmails} + /> + ) : ( + { + if (!value) { + return; + } + setEmail(value); + }} + /> + )} + + +
+ + + + + + )} + + {emailSent && ( + <> + { if (!value) { return; } - setEmail(value); + setOtp(value); }} /> - )} + - - - ) : ( - <> - { - if (!value) { - return; - } - setOtp(value); - }} - /> - - +
+ + + + )}
diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx index 8c3bdc7..e440e16 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx @@ -5,6 +5,8 @@ import { consumeCode, createCode } from "supertokens-auth-react/recipe/passwordl import { User } from "supertokens-web-js/types"; import { usePluginContext } from "../../plugin"; +import { FormActions } from "../form-actions"; +import { FormRow } from "../form-item"; import style from "./security-section.module.css"; @@ -109,41 +111,56 @@ export const MfaFactorPhoneOtpConfig = ({ user, onSuccess }: { user: User; onSuc } return ( -
- { - if (!value) { - return; - } - setSelectedPhoneNumber(value as string); - }} - /> - {!codeSent ? ( - - ) : ( +
+ {!codeSent && ( <> + + { + if (!value) { + return; + } + setSelectedPhoneNumber(value as string); + }} + /> + +
- setCode(value as string)} /> - + + + + + + )} + + {codeSent && ( + <> + + setCode(value as string)} + /> + + +
+ + + + )}
@@ -212,47 +229,59 @@ export const MfaFactorPhoneOtpSetup = ({ user, onSuccess }: { user: User; onSucc ); return ( -
- {!smsSent ? ( +
+ {!smsSent && ( <> - { - if (!value) { - return; - } - setPhoneNumber(value); - }} - /> - - + + { + if (!value) { + return; + } + setPhoneNumber(value); + }} + /> + + +
+ + + + - ) : ( + )} + + {smsSent && ( <> - { - if (!value) { - return; - } - setOtp(value); - }} - /> - - + + { + if (!value) { + return; + } + setOtp(value); + }} + /> + + +
+ + + + )}
diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx index cb60791..7e55964 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx @@ -1,6 +1,6 @@ -import { Button, Tag, usePrettyAction, TextInput } from "@shared/ui"; +import { Button, usePrettyAction, TextInput } from "@shared/ui"; import classNames from "classnames/bind"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useCallback } from "react"; import QRCode from "react-qr-code"; import { listDevices, @@ -12,20 +12,26 @@ import { import { User } from "supertokens-web-js/types"; import { usePluginContext } from "../../plugin"; +import { ListCard, ListCardFooter, ListCardItem, ListCardItemActions } from "../list-card"; import style from "./security-section.module.css"; const cx = classNames.bind(style); -export const MfaFactorTotpConfig = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { +export const MfaFactorTotpList = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { const [totpDevices, setTotpDevices] = useState<{ name: string; verified: boolean }[]>([]); - const [activeTotp, setActiveTotp] = useState<{ + const [newTotpDevice, setNewTotpDevice] = useState<{ + name: string; + qrString: string; + }>(); + const [totpVerificationCode, setTotpVerificationCode] = useState(""); + const [renameName, setRenameName] = useState(""); + const [addName, setAddName] = useState(""); + const [activeTotpDevice, setActiveTotpDevice] = useState<{ action: "add" | "rename"; name: string; qrCodeString: string; }>(); - const [verifyCode, setVerifyCode] = useState(""); - const [name, setName] = useState(""); const { t, api } = usePluginContext(); @@ -36,415 +42,247 @@ export const MfaFactorTotpConfig = ({ user, onSuccess }: { user: User; onSuccess } setTotpDevices( - devices.devices.map((device) => ({ - name: device.name, - verified: device.verified, - })), + devices.devices + .map((device) => ({ + name: device.name, + verified: device.verified, + })) + .filter((device) => device.verified), ); }, []); - const removeTotp = usePrettyAction( - async (deviceName: string) => { - await removeDevice({ deviceName }); - - if (activeTotp?.name === deviceName) { - setActiveTotp(undefined); - } - - loadTotps(); - }, - [activeTotp], - { - successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_REMOVE_TOTP"), - errorMessage: t("PL_SEC_MFA_TOTP_ERROR_REMOVE_TOTP"), - }, - ); - const addTotp = usePrettyAction( async () => { - const res = await createDevice({ deviceName: name || undefined }); + const res = await createDevice({ deviceName: addName || undefined }); if (res.status !== "OK") { throw new Error(t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP")); } - setName(""); - setVerifyCode(""); + + setAddName(""); + setTotpVerificationCode(""); loadTotps(); - setActiveTotp({ - action: "add", - name: res.deviceName, - qrCodeString: res.qrCodeString, - }); - }, - [name], - { - successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_ADD_TOTP"), - errorMessage: t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP"), + setNewTotpDevice({ name: res.deviceName, qrString: res.qrCodeString }); }, + [addName], + { errorMessage: t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP") }, ); const verifyTotp = usePrettyAction( async () => { - if (!activeTotp) { - throw new Error(t("PL_SEC_MFA_TOTP_ERROR_NO_TOTP_CONFIGURABLE")); - } - if (activeTotp.action !== "add") { - throw new Error(t("PL_SEC_MFA_TOTP_ERROR_NO_TOTP_CONFIGURABLE")); - } - - if (!verifyCode) { - throw new Error(t("PL_SEC_MFA_TOTP_ERROR_NO_CODE")); + let res: { status: string }; + if (!newTotpDevice) { + res = await verifyCode({ totp: totpVerificationCode }); + } else { + res = await verifyDevice({ deviceName: addName || newTotpDevice.name, totp: totpVerificationCode }); } - const res = await verifyDevice({ - deviceName: name || activeTotp.name, - totp: verifyCode, - }); if (res.status !== "OK") { throw new Error(t("PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE")); } - setActiveTotp(undefined); - setVerifyCode(""); + setNewTotpDevice(undefined); + setTotpVerificationCode(""); loadTotps(); }, - [activeTotp, verifyCode, name], + [newTotpDevice, totpVerificationCode, addName], { - successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_VERIFY_DEVICE"), + onSuccess, + successMessage: t("PL_SEC_MFA_TOTP_SETUP_SUCCESS_VERIFY_DEVICE"), errorMessage: t("PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE"), }, ); + const onAddNameChange = useCallback((value: string) => { + setAddName(value); + }, []); + + const removeTotp = usePrettyAction( + async (deviceName: string) => { + await removeDevice({ deviceName }); + + if (activeTotpDevice?.name === deviceName) { + setActiveTotpDevice(undefined); + } + + loadTotps(); + }, + [activeTotpDevice], + { + successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_REMOVE_TOTP"), + errorMessage: t("PL_SEC_MFA_TOTP_ERROR_REMOVE_TOTP"), + }, + ); + const renameTotp = usePrettyAction(async (name: string) => { - setActiveTotp({ action: "rename", name, qrCodeString: "" }); + setActiveTotpDevice({ action: "rename", name, qrCodeString: "" }); + setRenameName(name); + }, []); + + const cancelRename = useCallback(() => { + setRenameName(""); + setActiveTotpDevice(undefined); }, []); const updateTotpName = usePrettyAction( async () => { - if (activeTotp?.action !== "rename") { + if (activeTotpDevice?.action !== "rename") { throw new Error(t("PL_SEC_MFA_TOTP_ERROR_NO_TOTP_CONFIGURABLE")); } const res = await api.updateMfaTotpName({ - name: activeTotp.name, - newName: name, + name: activeTotpDevice.name, + newName: renameName, }); if (res.status !== "OK") { throw new Error(res.message); } - setActiveTotp(undefined); - setName(""); + setActiveTotpDevice(undefined); + setRenameName(""); loadTotps(); }, - [activeTotp, name], + [activeTotpDevice, renameName], { successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_UPDATE_NAME"), errorMessage: t("PL_SEC_MFA_TOTP_ERROR_UPDATE_NAME"), }, ); + const isRenaming = (totpName: string) => + Boolean(activeTotpDevice) && activeTotpDevice?.action === "rename" && activeTotpDevice.name === totpName; + useEffect(() => { loadTotps(); }, []); - const isAdding = !activeTotp || activeTotp.action === "add"; - const isRenaming = Boolean(activeTotp) && activeTotp?.action === "rename"; - return ( -
- {totpDevices.map((totp) => ( -
- {totp.name} - {totp.verified ? ( - - {t("PL_SEC_MFA_TOTP_VERIFIED")} - - ) : ( - - {t("PL_SEC_MFA_TOTP_UNVERIFIED")} - - )} +
+ + {totpDevices.map((totp) => ( + + {!isRenaming(totp.name) && {totp.name}} + + {isRenaming(totp.name) && ( + <> +
+ {t("PL_SEC_MFA_TOTP_RENAME_DEVICE_NAME_LABEL")} +
-
- - - -
-
- ))} - - {isRenaming && ( -
-

{t("PL_SEC_MFA_TOTP_RENAME_DEVICE")}

- -

- {t("PL_SEC_MFA_TOTP_RENAME_DEVICE_DESCRIPTION")} -

-
- setName(value as string)} - /> -
- -
- )} - - {isAdding && ( -
-

{t("PL_SEC_MFA_TOTP_ADD_DEVICE")}

- - {!activeTotp && ( - <> -

- {t("PL_SEC_MFA_TOTP_ADD_DEVICE_DESCRIPTION")} -

-
-
setName(value as string)} + value={renameName} + onChange={(value) => setRenameName(value as string)} /> -
- -
- - )} + + )} - {activeTotp && ( - <> -

- {t("PL_SEC_MFA_TOTP_VERIFY_DESCRIPTION")} -

- -
- -
- -
- -
- -
- setVerifyCode(value as string)} - /> -
- -
- - )} -
- )} -
- ); -}; - -export const MfaFactorTotpSetup = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { - const [totpDevices, setTotpDevices] = useState<{ name: string; verified: boolean }[]>(); - const [totpDevice, setTotpDevice] = useState<{ - name: string; - qrString: string; - }>(); - const [totp, setTotp] = useState(""); - const [name, setName] = useState(""); - - const { t } = usePluginContext(); - - const loadTotps = usePrettyAction(async () => { - const devices = await listDevices(); - if (devices.status !== "OK") { - throw new Error(t("PL_SEC_MFA_TOTP_ERROR_LOADING_TOTP")); - } - - setTotpDevices( - devices.devices.map((device) => ({ - name: device.name, - verified: device.verified, - })), - ); - }, []); - - const hasTotpSetup = useMemo(() => { - if (!totpDevices) { - return true; - } - return totpDevices.filter((device) => device.verified).length > 0; - }, [totpDevices]); - - const addTotp = usePrettyAction( - async () => { - const res = await createDevice({ deviceName: name || undefined }); - if (res.status !== "OK") { - throw new Error(t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP")); - } - setName(""); - setTotp(""); - - loadTotps(); - - setTotpDevice({ name: res.deviceName, qrString: res.qrCodeString }); - }, - [name], - { errorMessage: t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP") }, - ); - - const verifyTotp = usePrettyAction( - async () => { - let res: { status: string }; - if (!totpDevice) { - res = await verifyCode({ totp }); - } else { - res = await verifyDevice({ deviceName: name || totpDevice.name, totp }); - } - - if (res.status !== "OK") { - throw new Error(t("PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE")); - } - - setTotpDevice(undefined); - setTotp(""); + + {isRenaming(totp.name) && ( + <> + - loadTotps(); - }, - [totpDevice, totp, name], - { - onSuccess, - successMessage: t("PL_SEC_MFA_TOTP_SETUP_SUCCESS_VERIFY_DEVICE"), - errorMessage: t("PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE"), - }, - ); + + + )} - useEffect(() => { - loadTotps(); - }, []); + {!isRenaming(totp.name) && ( + <> + + + + + )} + + + ))} - return ( -
- {hasTotpSetup ? ( - <> -

{t("PL_SEC_MFA_TOTP_SETUP_CONFIRM_DEVICE")}

- -
-
- setTotp(value as string)} - /> -
- -
- - ) : ( - <> -

{t("PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE")}

- - {!totpDevice && ( + + {!newTotpDevice && ( <> -

- {t("PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_DESCRIPTION")} -

-
-
- setName(value as string)} - /> -
- +
+ {t("PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_NAME_LABEL")}
+ + + + )} - {totpDevice && ( + {newTotpDevice && ( <> -

+

{t("PL_SEC_MFA_TOTP_SETUP_VERIFY_DESCRIPTION")}

- {totpDevice?.qrString && ( - <> -
-
- -
- + {newTotpDevice?.qrString && ( +
+ +
)} -
-
- setTotp(value as string)} - /> -
- -
+ setTotpVerificationCode(value as string)} + /> + + )} - - )} + +
); }; -export default { Config: MfaFactorTotpConfig, Setup: MfaFactorTotpSetup }; +export default { Config: MfaFactorTotpList, Setup: MfaFactorTotpList }; diff --git a/packages/profile-security-react/src/components/security-section/mfa-section.tsx b/packages/profile-security-react/src/components/security-section/mfa-section.tsx index 1c62b09..2879221 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-section.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-section.tsx @@ -161,7 +161,7 @@ export const MfaSection = ({
{factor.setup && ( setFactorBeingSetup(factor.id as keyof typeof manageFactorComponents)} - className={cx("plugin-profile-security-second-factor-method-action")}> + className={cx("supertokens-plugin-profile-security-method-action")}> {t("PL_SEC_MFA_SETUP_BUTTON")} )} @@ -187,7 +187,7 @@ export const MfaSection = ({ appearance="plain" size="small" onClick={() => setFactorBeingSetup(undefined)} - className={cx("plugin-profile-security-second-factor-method-action")}> + className={cx("supertokens-plugin-profile-security-method-action")}> {t("PL_SEC_MFA_SETUP_CANCEL_BUTTON")} )} diff --git a/packages/profile-security-react/src/components/security-section/security-section.module.css b/packages/profile-security-react/src/components/security-section/security-section.module.css index 646cea4..dc3c1b9 100644 --- a/packages/profile-security-react/src/components/security-section/security-section.module.css +++ b/packages/profile-security-react/src/components/security-section/security-section.module.css @@ -49,50 +49,12 @@ margin-top: 0; } -.supertokens-plugin-profile-security-edit-form { +.supertokens-plugin-profile-security-form { display: flex; flex-direction: column; gap: 24px; } -.supertokens-plugin-profile-security-edit-form wa-input.st-input::part(label) { - display: none; -} - -.supertokens-plugin-profile-security-item { - display: flex; - flex-direction: row; - gap: 0; - align-items: start; -} - -.supertokens-plugin-profile-security-label { - font-weight: var(--wa-font-weight-semibold); - color: var(--wa-color-neutral-85); - font-size: var(--wa-font-size-m); - line-height: 1.5; - width: 260px; -} - -.supertokens-plugin-profile-security-required { - color: var(--wa-color-danger-70); -} - -.supertokens-plugin-profile-security-value { - flex: 1; - color: var(--wa-color-neutral-95); - font-size: var(--wa-font-size-m); - line-height: 1.5; - font-weight: var(--wa-font-weight-normal); -} - -.supertokens-plugin-profile-security-form-actions { - display: flex; - flex-direction: row; - justify-content: flex-end; - gap: 10px; -} - .supertokens-plugin-profile-security-no-linked-accounts { margin-bottom: 13px; color: var(--wa-color-neutral-95); @@ -156,7 +118,7 @@ } .supertokens-plugin-profile-security-passkey-email-select { - width: 100%; + flex: 1; } .supertokens-plugin-profile-security-passkey-email-select-label { @@ -212,44 +174,41 @@ margin-left: auto; } -/* ------------------------------------------------------------ */ - -.plugin-profile-security-link { - color: #07c; - text-decoration: none; - - &:hover { - text-decoration: underline; - } +.supertokens-plugin-profile-security-second-factor-manage { + margin-top: 14px; } -.plugin-profile-security-second-factor-method-action { - margin-left: auto; +/* TOTP */ +.supertokens-plugin-profile-security-totp-name-label { + font-size: 14px; + line-height: 1.4; + font-weight: 500; + word-break: keep-all; } - -.plugin-profile-security-second-factor-method-button { - margin-left: auto; +.supertokens-plugin-profile-security-totp-name { + flex: 1; } -.plugin-profile-security-manage { - margin-top: var(--plugin-spacing-xl); - padding-left: var(--plugin-spacing-xl); - border-left: 2px solid var(--plugin-color-primary); +.supertokens-plugin-profile-security-totp-verify-description { + flex: 1 1 100%; + order: -1; + margin-bottom: 10px; } -.plugin-profile-security-item-description { - color: var(--plugin-text-secondary); - font-size: var(--plugin-font-size-sm); - line-height: var(--plugin-line-height-tight); +.supertokens-plugin-profile-security-totp-verify-qr { + display: flex; + align-items: center; + flex-direction: column; +} +.supertokens-plugin-profile-security-totp-verify-qr svg { + width: 165px; + height: auto; } -.plugin-profile-security-second-factor-manage-totp-verify-description, -.plugin-profile-security-second-factor-manage-totp-verify-qr { - margin-bottom: var(--plugin-spacing-md); +.supertokens-plugin-profile-security-totp-verify-code { + flex: 1; } -.plugin-profile-security-second-factor-manage-totp-verify-qr { - display: flex; - align-items: center; - flex-direction: column; +.supertokens-plugin-profile-security-method-action { + margin-left: auto; } diff --git a/packages/profile-security-react/src/components/security-section/set-password-section.tsx b/packages/profile-security-react/src/components/security-section/set-password-section.tsx index 3e1cc86..91ea538 100644 --- a/packages/profile-security-react/src/components/security-section/set-password-section.tsx +++ b/packages/profile-security-react/src/components/security-section/set-password-section.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from "react"; import { User } from "supertokens-web-js/types"; import { usePluginContext } from "../../plugin"; +import { FormActions } from "../form-actions"; +import { FormRow } from "../form-item"; import style from "./security-section.module.css"; @@ -89,70 +91,58 @@ export const SetPasswordSection = ({ } return ( -
-
- - {t("PL_SEC_SET_PASSWORD_SELECT_EMAIL_LABEL")} - - - - { - if (!value) { - return; - } - setPasswordSetEmail(value as string); - }} - options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} - disabled={(user?.emails.length ?? 0) <= 1} - /> - -
- -
- - {t("PL_SEC_SET_PASSWORD_PASSWORD_LABEL")} - - - - {passwordInputVisible ? ( - <> - -
- - - ) : ( - - )} -
-
+ + + { + if (!value) { + return; + } + setPasswordSetEmail(value as string); + }} + options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} + disabled={(user?.emails.length ?? 0) <= 1} + /> + + + + {passwordInputVisible ? ( + <> + +
+ + + ) : ( + + )} +
{passwordInputVisible && ( -
+ -
+ )}
); diff --git a/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx b/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx index 4abf08f..2b1bb5d 100644 --- a/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx +++ b/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx @@ -5,6 +5,8 @@ import { registerCredentialWithSignUp } from "supertokens-auth-react/recipe/weba import { User } from "supertokens-web-js/types"; import { usePluginContext } from "../../plugin"; +import { FormActions } from "../form-actions"; +import { FormRow } from "../form-item"; import style from "./security-section.module.css"; @@ -55,33 +57,27 @@ export const SetWebAuthnSection = ({ } return ( -
-
- - {t("PL_SEC_SET_WEBAUTHN_SELECT_EMAIL_LABEL")} - + + + { + if (!value) { + return; + } + setWebauthnSetEmail(value as string); + }} + options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} + disabled={(user?.emails.length ?? 0) <= 1} + /> + - - { - if (!value) { - return; - } - setWebauthnSetEmail(value as string); - }} - options={user?.emails.map((email) => ({ label: email, value: email })) ?? []} - disabled={(user?.emails.length ?? 0) <= 1} - /> - -
- -
+ -
+
); }; diff --git a/packages/profile-security-react/src/components/security-section/webauthn-section.tsx b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx index 42db58d..8df15b6 100644 --- a/packages/profile-security-react/src/components/security-section/webauthn-section.tsx +++ b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx @@ -9,7 +9,7 @@ import { import { User } from "supertokens-web-js/types"; import { usePluginContext } from "../../plugin"; -import { ListCard } from "../list-card"; +import { ListCard, ListCardFooter, ListCardItem, ListCardItemActions } from "../list-card"; import style from "./security-section.module.css"; @@ -132,61 +132,58 @@ export const WebauthnSection = ({ return (
- ({ - Content: () => ( - <> - {webauthnEmail} - - {new Date(credential.createdAt).toLocaleString()} - - - ), - Actions: () => ( - - ), - }))} - FooterComponent={() => ( - <> -
- {t("PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL")} -
- - { - if (!value) { - return; - } - setWebauthnEmail(value as string); - }} - options={webAuthnEmails.map((email) => ({ label: email, value: email })) ?? []} - disabled={webAuthnEmails.length <= 1} - /> - - - - )} - /> + + {credentials.map((credential, index) => ( + + {webauthnEmail} + + {new Date(credential.createdAt).toLocaleString()} + + + + + + + ))} + + +
+ {t("PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL")} +
+ + { + if (!value) { + return; + } + setWebauthnEmail(value as string); + }} + options={webAuthnEmails.map((email) => ({ label: email, value: email })) ?? []} + disabled={webAuthnEmails.length <= 1} + /> + + +
+
); }; diff --git a/packages/profile-security-react/src/translations.ts b/packages/profile-security-react/src/translations.ts index 87623c7..3974080 100644 --- a/packages/profile-security-react/src/translations.ts +++ b/packages/profile-security-react/src/translations.ts @@ -30,7 +30,6 @@ export const defaultTranslationsSecurity = { PL_SEC_CHANGE_PASSWORD_CANCEL_BUTTON: "Cancel", PL_SEC_CHANGE_PASSWORD_SHOW_BUTTON: "Change Password", - PL_SEC_CONFIRM_NEW_PASSWORD: "Confirm new password", PL_SEC_CHANGE_PASSWORD_SUCCESS_CHANGE: "Password changed successfully", PL_SEC_CHANGE_PASSWORD_ERROR_CHANGE: "Failed to change password", @@ -52,16 +51,18 @@ export const defaultTranslationsSecurity = { PL_SEC_MFA_ERROR_TOGGLE_SECONDARY_FACTOR: "Failed to change secondary factor", PL_SEC_MFA_ERROR_WRONG_CONFIGURATION: "Wrong MFA configuration. Please contact support.", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_CODE_LABEL: "Code", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_CODE_PLACEHOLDER: "The code you received on your phone", PL_SEC_MFA_CHANGE_PHONE_NUMBER_LABEL: "Change phone number", - PL_SEC_MFA_CHANGE_PHONE_NUMBER_SEND_CODE_BUTTON: "Send code", - PL_SEC_MFA_CHANGE_PHONE_NUMBER_CHANGE_BUTTON: "Change phone number", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_SEND_CODE_BUTTON: "Send Code", + PL_SEC_MFA_CHANGE_PHONE_NUMBER_CHANGE_BUTTON: "Change Phone Number", PL_SEC_MFA_CHANGE_PHONE_NUMBER_SUCCESS_CHANGE: "Phone number changed successfully", PL_SEC_MFA_CHANGE_PHONE_NUMBER_SUCCESS_SEND_CODE: "A code has been sent to your phone", PL_SEC_MFA_CHANGE_PHONE_NUMBER_ERROR_CHANGE: "Failed to change phone number", PL_SEC_MFA_CHANGE_PHONE_NUMBER_ERROR_SEND_CODE: "Failed to send code", PL_SEC_MFA_SETUP_PHONE_LABEL: "Phone number", PL_SEC_MFA_SETUP_PHONE_PLACEHOLDER: "Enter your phone number", - PL_SEC_MFA_SETUP_PHONE_SEND_BUTTON: "Send code", + PL_SEC_MFA_SETUP_PHONE_SEND_BUTTON: "Send Code", PL_SEC_MFA_SETUP_PHONE_CODE_LABEL: "Code", PL_SEC_MFA_SETUP_PHONE_CODE_PLACEHOLDER: "The code you received on your phone", PL_SEC_MFA_SETUP_PHONE_VERIFY_BUTTON: "Verify", @@ -71,54 +72,37 @@ export const defaultTranslationsSecurity = { PL_SEC_MFA_SETUP_PHONE_ERROR_VERIFY: "Failed to verify code", PL_SEC_MFA_SETUP_PHONE_SUCCESS_VERIFY: "Phone number verified successfully. You can now enable this factor.", - PL_SEC_MFA_TOTP_VERIFIED: "Verified", - PL_SEC_MFA_TOTP_UNVERIFIED: "Unverified", - PL_SEC_MFA_TOTP_ADD_BUTTON: "Add TOTP", - PL_SEC_MFA_TOTP_ADD_DEVICE: "Add device", - PL_SEC_MFA_TOTP_ADD_DEVICE_DESCRIPTION: "Enter a name for your device", - PL_SEC_MFA_TOTP_ADD_DEVICE_NAME_LABEL: "Name", - PL_SEC_MFA_TOTP_ADD_DEVICE_NAME_PLACEHOLDER: "If not provided, a default name will be used.", + PL_SEC_MFA_TOTP_LIST_TITLE: "Device List", PL_SEC_MFA_TOTP_REMOVE_BUTTON: "Remove", PL_SEC_MFA_TOTP_RENAME_BUTTON: "Rename", - PL_SEC_MFA_TOTP_RENAME_DEVICE: "Rename device", - PL_SEC_MFA_TOTP_RENAME_DEVICE_DESCRIPTION: "Enter a new name for your device", + PL_SEC_MFA_TOTP_CANCEL_RENAME_BUTTON: "Cancel", PL_SEC_MFA_TOTP_RENAME_DEVICE_NAME_LABEL: "Name", PL_SEC_MFA_TOTP_RENAME_DEVICE_NAME_PLACEHOLDER: "Name of the device", - PL_SEC_MFA_TOTP_VERIFY_DESCRIPTION: "Scan the QR code with your authenticator app", - PL_SEC_MFA_TOTP_VERIFY_BUTTON: "Verify", - PL_SEC_MFA_TOTP_VERIFY_CODE_LABEL: "Code", - PL_SEC_MFA_TOTP_VERIFY_CODE_PLACEHOLDER: "Enter the code from your authenticator app", PL_SEC_MFA_TOTP_SUCCESS_REMOVE_TOTP: "TOTP device removed successfully", - PL_SEC_MFA_TOTP_SUCCESS_ADD_TOTP: "TOTP device added successfully", - PL_SEC_MFA_TOTP_SUCCESS_VERIFY_DEVICE: "TOTP device verified successfully", PL_SEC_MFA_TOTP_SUCCESS_UPDATE_NAME: "Name updated successfully", PL_SEC_MFA_TOTP_ERROR_LOADING_TOTP: "Failed to load TOTP devices", PL_SEC_MFA_TOTP_ERROR_REMOVE_TOTP: "Failed to remove TOTP device", PL_SEC_MFA_TOTP_ERROR_ADD_TOTP: "Failed to add TOTP device", PL_SEC_MFA_TOTP_ERROR_NO_TOTP_CONFIGURABLE: "No valid TOTP configurable", - PL_SEC_MFA_TOTP_ERROR_NO_CODE: "Please enter a code", PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE: "Failed to verify device", PL_SEC_MFA_TOTP_ERROR_UPDATE_NAME: "Failed to change name", PL_SEC_MFA_TOTP_SETUP_SUCCESS_VERIFY_DEVICE: "TOTP device verified successfully. You can now enable this factor.", - PL_SEC_MFA_TOTP_SETUP_CONFIRM_DEVICE: "Confirm device", PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE: "Add device", - PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_DESCRIPTION: "Scan the QR code with your authenticator app", PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_NAME_LABEL: "Name", PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_NAME_PLACEHOLDER: "If not provided, a default name will be used.", - PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_BUTTON: "Add device", - PL_SEC_MFA_TOTP_SETUP_VERIFY_DESCRIPTION: "Enter the code from your authenticator app", + PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_BUTTON: "Add Device", + PL_SEC_MFA_TOTP_SETUP_VERIFY_DESCRIPTION: "Scan the QR code with authenticator app", PL_SEC_MFA_TOTP_SETUP_VERIFY_BUTTON: "Verify", - PL_SEC_MFA_TOTP_SETUP_VERIFY_CODE_LABEL: "Code", PL_SEC_MFA_TOTP_SETUP_VERIFY_CODE_PLACEHOLDER: "Enter the code from your authenticator app", PL_SEC_MFA_CHANGE_EMAIL_LABEL: "Change email", - PL_SEC_MFA_CHANGE_EMAIL_BUTTON: "Change email", + PL_SEC_MFA_CHANGE_EMAIL_BUTTON: "Change Email", PL_SEC_MFA_CHANGE_EMAIL_SUCCESS_CHANGE: "Email changed successfully", PL_SEC_MFA_CHANGE_EMAIL_ERROR_CHANGE: "Failed to change email", PL_SEC_MFA_SETUP_EMAIL_LABEL: "Email", PL_SEC_MFA_SETUP_EMAIL_PLACEHOLDER: "Enter your email", - PL_SEC_MFA_SETUP_EMAIL_SEND_BUTTON: "Send code", + PL_SEC_MFA_SETUP_EMAIL_SEND_BUTTON: "Send Code", PL_SEC_MFA_SETUP_EMAIL_SUCCESS_SEND: "A code has been sent to your email", PL_SEC_MFA_SETUP_EMAIL_ERROR_EMAIL_INVALID: "Email is invalid", PL_SEC_MFA_SETUP_EMAIL_ERROR_SEND: "Failed to send code", @@ -139,7 +123,7 @@ export const defaultTranslationsSecurity = { PL_SEC_WEBAUTHN_SUCCESS_ADD_CREDENTIAL: "Passkey added successfully", PL_SEC_WEBAUTHN_ERROR_REMOVE_CREDENTIAL: "Failed to remove Passkey", PL_SEC_WEBAUTHN_ERROR_LOAD_CREDENTIALS: "Failed to load Passkeys", - PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL: "Select\u00A0Email", + PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL: "Select\u00A0Email", // needs non breakable space PL_SEC_WEBAUTHN_REMOVE_BUTTON: "Remove", PL_SEC_WEBAUTHN_ADD_CREDENTIAL_BUTTON: "Add Passkey", }, diff --git a/shared/ui/src/theme/wa/components/input.css b/shared/ui/src/theme/wa/components/input.css index 300f964..005ff4e 100644 --- a/shared/ui/src/theme/wa/components/input.css +++ b/shared/ui/src/theme/wa/components/input.css @@ -10,6 +10,9 @@ wa-input[appearance~="plain"] { --wa-form-control-border-width: 0; } +wa-input:not([label])::part(label) { + display: none; +} wa-input::part(label) { margin-block-end: 0.375rem; } From fdabfc7bc17b3dd7991587ecc7b58ae597077a64 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 13 Oct 2025 11:29:36 +0300 Subject: [PATCH 06/11] cleanup 2 --- packages/profile-security-nodejs/package.json | 4 +- .../profile-security-nodejs/src/constants.ts | 7 + .../src/implementation.ts | 591 +++++++++++++++ .../src/plugin.test.ts | 674 ++++++++++++++++++ .../profile-security-nodejs/src/plugin.ts | 455 +++--------- packages/profile-security-nodejs/src/types.ts | 2 + .../security-section/mfa-factor-phone-otp.tsx | 6 +- 7 files changed, 1361 insertions(+), 378 deletions(-) create mode 100644 packages/profile-security-nodejs/src/implementation.ts create mode 100644 packages/profile-security-nodejs/src/plugin.test.ts diff --git a/packages/profile-security-nodejs/package.json b/packages/profile-security-nodejs/package.json index d5393de..c340d5f 100644 --- a/packages/profile-security-nodejs/package.json +++ b/packages/profile-security-nodejs/package.json @@ -12,7 +12,7 @@ "build": "vite build && npm run pretty", "pretty": "npx pretty-quick .", "pretty-check": "npx pretty-quick --check .", - "test": "TEST_MODE=testing vitest run --pool=forks" + "test": "TEST_MODE=testing vitest run --pool=forks --testTimeout=30000" }, "keywords": [ "progressive-profiling", @@ -39,4 +39,4 @@ "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts" -} +} \ No newline at end of file diff --git a/packages/profile-security-nodejs/src/constants.ts b/packages/profile-security-nodejs/src/constants.ts index 912aa1b..a94ea87 100644 --- a/packages/profile-security-nodejs/src/constants.ts +++ b/packages/profile-security-nodejs/src/constants.ts @@ -5,3 +5,10 @@ export const PLUGIN_SDK_VERSION = ["23.0.1", ">=23.0.1"]; export const HANDLE_BASE_PATH = `/plugin/${PLUGIN_ID}`; export const METADATA_KEY = `${PLUGIN_ID}`; + +export const DEFAULT_ENABLE_SETTING_PASSWORD = true; +export const DEFAULT_ENABLE_THIRD_PARTY_LINKING = true; +export const DEFAULT_ENABLE_MFA_CONFIGURATION = true; + +// todo is this correct? +export const DEFAULT_SMS_CODE_LIFETIME_FOR_OTP_PHONE_CHANGE = 1000 * 60 * 5; // 5 minutes diff --git a/packages/profile-security-nodejs/src/implementation.ts b/packages/profile-security-nodejs/src/implementation.ts new file mode 100644 index 0000000..489cf67 --- /dev/null +++ b/packages/profile-security-nodejs/src/implementation.ts @@ -0,0 +1,591 @@ +import { getUser, deleteUser, getAvailableFirstFactors, User } from "supertokens-node"; +import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; +import AccountLinking from "supertokens-node/recipe/accountlinking"; +import { FactorIds } from "supertokens-node/recipe/multifactorauth"; +import { signUp, updateEmailOrPassword, verifyCredentials } from "supertokens-node/recipe/emailpassword"; +import { BaseFormSection } from "@supertokens-plugins/profile-details-shared"; +import { SuperTokensPluginProfileSecurityNormalisedConfig } from "./types"; +import { logDebugMessage } from "./logger"; +import MultiFactorAuth from "supertokens-node/recipe/multifactorauth"; +import Passwordless from "supertokens-node/recipe/passwordless"; +import { DEFAULT_SMS_CODE_LIFETIME_FOR_OTP_PHONE_CHANGE } from "./constants"; +import TOTP from "supertokens-node/recipe/totp"; + +export class Implementation { + static instance: Implementation | undefined; + + protected sections: BaseFormSection[] = []; + + static init(pluginConfig: SuperTokensPluginProfileSecurityNormalisedConfig): Implementation { + if (Implementation.instance) { + return Implementation.instance; + } + Implementation.instance = new Implementation(pluginConfig); + + return Implementation.instance; + } + + static getInstanceOrThrow(): Implementation { + if (!Implementation.instance) { + throw new Error("Implementation instance not found. Make sure you have initialized the plugin."); + } + + return Implementation.instance; + } + + static reset(): void { + Implementation.instance = undefined; + } + + constructor(protected pluginConfig: SuperTokensPluginProfileSecurityNormalisedConfig) {} + + getConfigForClient = async function ( + this: Implementation, + // props needed for overriding + // eslint-disable-next-line @typescript-eslint/no-unused-vars + props: { userContext: any; session: SessionContainerInterface }, + ): Promise< + { status: "OK"; config: SuperTokensPluginProfileSecurityNormalisedConfig } | { status: "ERROR"; message: string } + > { + return { + status: "OK", + config: { + enableSettingPassword: this.pluginConfig.enableSettingPassword, + enableThirdPartyLinkning: this.pluginConfig.enableThirdPartyLinkning, + enableMfaConfiguration: this.pluginConfig.enableMfaConfiguration, + }, + }; + }; + + setUserPassword = async function ( + this: Implementation, + { + userId, + email, + password, + session, + userContext, + }: { + userId: string; + email: string; + password: string; + session: SessionContainerInterface; + userContext: any; + }, + ): Promise<{ status: "OK" } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + // todo decide if we should allow setting password for other emails + if (!user.emails.includes(email)) { + return { + status: "ERROR", + message: "The user does not have this email address", + }; + } + + const passwordLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "emailpassword"); + if (passwordLoginMethods.length) { + return { + status: "ERROR", + message: "User already has a password set. Please use the change password feature to update your password.", + }; + } + + // todo firgure out how to handle email verification + const signUpResult = await signUp( + session!.getTenantId(), + email, + password, + session!, // todo: ??? should we pass the session or try linking later? + userContext, + ); + if (signUpResult.status === "EMAIL_ALREADY_EXISTS_ERROR") { + return { + status: "ERROR", + message: + "There already exists a user with this email address. Please use the change password feature to update your password.", + }; + } + if (signUpResult.status === "LINKING_TO_SESSION_USER_FAILED") { + return { + status: "ERROR", + message: "Could not link the new password to the user. Please contact support.", + }; + } + if (signUpResult.status !== "OK") { + return { + status: "ERROR", + message: "Password change failed", + }; + } + + const linkResp = await AccountLinking.linkAccounts(signUpResult.recipeUserId, session!.getUserId(), userContext); + if (linkResp.status !== "OK") { + logDebugMessage(`Could not link the new password to the user: ${linkResp.status}`); + + return { + status: "ERROR", + message: "Could not link the new password to the user. Please contact support.", + }; + } + + return { status: "OK" }; + }; + + selectLoginMethodForPasswordChange = async function ( + this: Implementation, + { user }: { user: User; session: SessionContainerInterface; userContext: any }, + ): Promise<{ status: "OK"; loginMethod: User["loginMethods"][number] } | { status: "ERROR"; message: string }> { + const passwordLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "emailpassword"); + if (passwordLoginMethods.length === 0) { + return { + status: "ERROR", + message: "User has no password set. Please set a password first.", + }; + } + if (passwordLoginMethods.length > 1) { + return { + status: "ERROR", + message: "User has multiple password login methods. Please contact support.", + }; + } + + return { status: "OK", loginMethod: passwordLoginMethods[0]! }; + }; + + changeUserPassword = async function ( + this: Implementation, + { + userId, + currentPassword, + newPassword, + session, + userContext, + }: { + userId: string; + currentPassword: string; + newPassword: string; + session: SessionContainerInterface; + userContext: any; + }, + ): Promise<{ status: "OK" } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const loginMethodSelectResult = await this.selectLoginMethodForPasswordChange({ user, session, userContext }); + if (loginMethodSelectResult.status !== "OK") { + return loginMethodSelectResult; + } + + if (!loginMethodSelectResult.loginMethod.email) { + return { status: "ERROR", message: "User has no email login method" }; + } + + const verifyResult = await verifyCredentials( + session!.getTenantId(), + loginMethodSelectResult.loginMethod.email, + currentPassword, + userContext, + ); + + if (verifyResult.status !== "OK") { + return { status: "ERROR", message: "Invalid password" }; + } + + const result = await updateEmailOrPassword({ + recipeUserId: loginMethodSelectResult.loginMethod.recipeUserId, + password: newPassword, + userContext, + }); + + if (result.status !== "OK") { + logDebugMessage(`Could not update password: ${result.status}`); + + return { + status: "ERROR", + message: "Password change failed", + }; + } + + return { status: "OK" }; + }; + + unlinkThirdPartyUser = async function ( + this: Implementation, + { + userId, + recipeUserId, + session, + userContext, + }: { + userId: string; + recipeUserId: string; + session: SessionContainerInterface; + userContext: any; + }, + ): Promise<{ status: "OK" } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const availableFirstFactors = await getAvailableFirstFactors(session!.getTenantId(), session, userContext); + + const availableUserLoginMethods = user.loginMethods.filter((lm) => lm.recipeUserId.getAsString() !== recipeUserId); + const availableUserFactorIds: string[] = availableUserLoginMethods + .map((lm) => { + if (lm.recipeId === "emailpassword") return [FactorIds.EMAILPASSWORD]; + if (lm.recipeId === "passwordless") { + if (lm.email) return [FactorIds.OTP_EMAIL, FactorIds.LINK_EMAIL]; + if (lm.phoneNumber) return [FactorIds.OTP_EMAIL, FactorIds.LINK_EMAIL]; + } + if (lm.recipeId === "thirdparty") return FactorIds.THIRDPARTY; + if (lm.recipeId === "webauthn") return [FactorIds.WEBAUTHN]; + + return undefined; + }) + .filter((factorIds) => factorIds !== undefined) + .flat(); + + const canUnlink = availableUserFactorIds.some((factorId) => availableFirstFactors.includes(factorId)); + if (!canUnlink) { + return { + status: "ERROR", + message: "User has no available first factor login methods", + }; + } + + const result = await deleteUser(recipeUserId, false, { + userContext, + }); + if (result.status !== "OK") { + return { + status: "ERROR", + message: "Could not unlink account", + }; + } + + return { status: "OK" }; + }; + + setOrRemoveSingleRequiredMfaFactorForUser = async function ( + this: Implementation, + { + userId, + factorId, + userContext, + }: { userId: string; factorId?: string; session: SessionContainerInterface; userContext: any }, + ): Promise<{ status: "OK" } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const requiredFactorIds = await MultiFactorAuth.getRequiredSecondaryFactorsForUser(userId, userContext); + for await (const factorId of requiredFactorIds) { + await MultiFactorAuth.removeFromRequiredSecondaryFactorsForUser(userId, factorId, userContext); + } + + if (factorId) { + await MultiFactorAuth.addToRequiredSecondaryFactorsForUser(userId, factorId, userContext); + } + + return { status: "OK" }; + }; + + selectLoginMethodForOtpEmailChange = async function ( + this: Implementation, + { user }: { user: User; session: SessionContainerInterface; userContext: any }, + ): Promise<{ status: "OK"; loginMethod: User["loginMethods"][number] } | { status: "ERROR"; message: string }> { + const emailOtpLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.email); + + if (emailOtpLoginMethods.length === 0) { + return { + status: "ERROR", + message: "User has no email OTP login method", + }; + } + if (emailOtpLoginMethods.length > 1) { + return { + status: "ERROR", + message: "User has multiple email OTP login methods", + }; + } + + return { status: "OK", loginMethod: emailOtpLoginMethods[0]! }; + }; + + // todo email validation? + changeOtpEmailForUser = async function ( + this: Implementation, + { + userId, + email, + userContext, + session, + }: { userId: string; email: string; userContext: any; session: SessionContainerInterface }, + ): Promise<{ status: "OK" } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const emailOtpLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.email); + if (emailOtpLoginMethods.length === 0) { + return { + status: "ERROR", + message: "User has no email OTP login method", + }; + } + if (emailOtpLoginMethods.length > 1) { + return { + status: "ERROR", + message: "User has multiple email OTP login methods", + }; + } + const loginMethodSelectResult = await this.selectLoginMethodForOtpEmailChange({ user, session, userContext }); + if (loginMethodSelectResult.status !== "OK") { + return loginMethodSelectResult; + } + + const result = await Passwordless.updateUser({ + recipeUserId: loginMethodSelectResult.loginMethod.recipeUserId, + email: email, + userContext, + }); + + if (result.status !== "OK") { + return { + status: "ERROR", + message: "Failed to update email OTP login method", + }; + } + + return { + status: "OK", + }; + }; + + getRequiredSecondaryFactorsForUser = async function ( + this: Implementation, + { userId, userContext }: { userId: string; userContext: any; session: SessionContainerInterface }, + ): Promise<{ status: "OK"; requiredSecondaryFactors: string[] } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const requiredSecondaryFactors = await MultiFactorAuth.getRequiredSecondaryFactorsForUser(user.id, userContext); + + return { + status: "OK", + requiredSecondaryFactors, + }; + }; + + selectLoginMethodForOtpPhoneNumberChange = async function ( + this: Implementation, + { user }: { user: User; session: SessionContainerInterface; userContext: any }, + ): Promise<{ status: "OK"; loginMethod: User["loginMethods"][number] } | { status: "ERROR"; message: string }> { + const phoneOtpLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.phoneNumber); + + if (phoneOtpLoginMethods.length === 0) { + return { + status: "ERROR", + message: "User has no phone OTP login method", + }; + } + if (phoneOtpLoginMethods.length > 1) { + return { + status: "ERROR", + message: "User has multiple phone OTP login methods", + }; + } + + return { status: "OK", loginMethod: phoneOtpLoginMethods[0]! }; + }; + + sendSmsOtpForUserPhoneNumberChange = async function ( + this: Implementation, + { + userId, + phoneNumber, + userContext, + session, + }: { userId: string; phoneNumber: string; userContext: any; session: SessionContainerInterface }, + ): Promise<{ status: "OK"; deviceId: string; preAuthSessionId: string } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const loginMethodSelectResult = await this.selectLoginMethodForOtpPhoneNumberChange({ user, session, userContext }); + if (loginMethodSelectResult.status !== "OK") { + return loginMethodSelectResult; + } + + const result = await Passwordless.createCode({ + phoneNumber, + tenantId: session!.getTenantId(), + session, + userContext, + }); + if (result.status !== "OK") { + return { + status: "ERROR", + message: "Failed to generate code", + }; + } + + await Passwordless.sendSms({ + isFirstFactor: false, + codeLifetime: DEFAULT_SMS_CODE_LIFETIME_FOR_OTP_PHONE_CHANGE, + phoneNumber, + preAuthSessionId: result.preAuthSessionId, + tenantId: session!.getTenantId(), + userContext, + userInputCode: result.userInputCode, + type: "PASSWORDLESS_LOGIN", + }); + + return { + status: "OK", + deviceId: result.deviceId, + preAuthSessionId: result.preAuthSessionId, + }; + }; + + changeOtpPhoneNumberForUser = async function ( + this: Implementation, + { + userId, + phoneNumber, + deviceId, + preAuthSessionId, + code, + userContext, + session, + }: { + userId: string; + phoneNumber: string; + deviceId: string; + preAuthSessionId: string; + code: string; + userContext: any; + session: SessionContainerInterface; + }, + ): Promise<{ status: "OK" } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const loginMethodSelectResult = await this.selectLoginMethodForOtpPhoneNumberChange({ user, session, userContext }); + if (loginMethodSelectResult.status !== "OK") { + return loginMethodSelectResult; + } + + const checkResult = await Passwordless.checkCode({ + deviceId, + preAuthSessionId, + userInputCode: code, + tenantId: session!.getTenantId(), + userContext, + }); + + if (checkResult.status !== "OK") { + return { + status: "ERROR", + message: "Failed to validate code", + }; + } + if (!checkResult.consumedDevice.phoneNumber) { + return { + status: "ERROR", + message: "Failed to validate code", + }; + } + if (checkResult.consumedDevice.phoneNumber !== phoneNumber) { + return { + status: "ERROR", + message: "Code is not valid for this phone number", + }; + } + + const updateResult = await Passwordless.updateUser({ + recipeUserId: loginMethodSelectResult.loginMethod.recipeUserId, + phoneNumber: checkResult.consumedDevice.phoneNumber, + userContext, + }); + if (updateResult.status !== "OK") { + return { + status: "ERROR", + message: "Failed to update phone OTP login method", + }; + } + + try { + await Passwordless.revokeAllCodes({ + phoneNumber: checkResult.consumedDevice.phoneNumber, + tenantId: session!.getTenantId(), + userContext, + }); + } catch (error) { + logDebugMessage( + `Failed to revoke all codes: ${error}. It doesn't matter it failed, since the code we'll be revoked anyway after a specific time.`, + ); + } + + return { + status: "OK", + }; + }; + + renameOtpTotpDeviceForUser = async function ( + this: Implementation, + { + userId, + name, + newName, + userContext, + }: { userId: string; name: string; newName: string; userContext: any; session: SessionContainerInterface }, + ): Promise<{ status: "OK" } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + const result = await TOTP.updateDevice(userId, name, newName, userContext); + + if (result.status !== "OK") { + return { + status: "ERROR", + message: "Could not update TOTP device", + }; + } + + return { + status: "OK", + }; + }; + + getUser = async function ( + this: Implementation, + { userId, userContext }: { userId: string; userContext: any; session: SessionContainerInterface }, + ): Promise<{ status: "OK"; user: any } | { status: "ERROR"; message: string }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "ERROR", message: "User not found" }; + } + + return { + status: "OK", + user: user.toJson(), + }; + }; +} diff --git a/packages/profile-security-nodejs/src/plugin.test.ts b/packages/profile-security-nodejs/src/plugin.test.ts new file mode 100644 index 0000000..e43cb2d --- /dev/null +++ b/packages/profile-security-nodejs/src/plugin.test.ts @@ -0,0 +1,674 @@ +import express from "express"; +import crypto from "node:crypto"; +import { describe, it, expect, afterEach, beforeEach } from "vitest"; + +import SuperTokens, { getUser } from "supertokens-node/lib/build/index"; +import Session from "supertokens-node/lib/build/recipe/session/index"; +import EmailPassword, { verifyCredentials } from "supertokens-node/lib/build/recipe/emailpassword/index"; +import ThirdParty from "supertokens-node/lib/build/recipe/thirdparty/index"; +import MultiFactorAuth from "supertokens-node/lib/build/recipe/multifactorauth/index"; +import Passwordless from "supertokens-node/lib/build/recipe/passwordless/index"; +import AccountLinking from "supertokens-node/recipe/accountlinking"; +import TOTP, { createDevice, listDevices } from "supertokens-node/lib/build/recipe/totp/index"; + +import { middleware, errorHandler } from "supertokens-node/framework/express"; +import { verifySession } from "supertokens-node/lib/build/recipe/session/framework/express"; + +import { ProcessState } from "supertokens-node/lib/build/processState"; +import SuperTokensRaw from "supertokens-node/lib/build/supertokens"; +import SessionRaw from "supertokens-node/lib/build/recipe/session/recipe"; +import UserRolesRaw from "supertokens-node/lib/build/recipe/userroles/recipe"; +import EmailPasswordRaw from "supertokens-node/lib/build/recipe/emailpassword/recipe"; +import ThirdPartyRaw from "supertokens-node/lib/build/recipe/thirdparty/recipe"; +import AccountLinkingRaw from "supertokens-node/lib/build/recipe/accountlinking/recipe"; +import MultitenancyRaw from "supertokens-node/lib/build/recipe/multitenancy/recipe"; +import UserMetadataRaw from "supertokens-node/lib/build/recipe/usermetadata/recipe"; +import MultiFactorAuthRaw from "supertokens-node/lib/build/recipe/multifactorauth/recipe"; +import PasswordlessRaw from "supertokens-node/lib/build/recipe/passwordless/recipe"; +import TOTPRaw from "supertokens-node/lib/build/recipe/totp/recipe"; + +import { init } from "./plugin"; +import { Implementation } from "./implementation"; +import { HANDLE_BASE_PATH } from "./constants"; +import { SuperTokensPluginProfileSecurityConfig } from "./types"; + +const testPORT = parseInt(process.env.PORT || "3000"); +const getTestEmail = () => `user+${Math.random() * 1000000}@test.com`; +const testPW = "test"; + +describe("profile-security-nodejs", () => { + describe("API Endpoints", () => { + afterEach(() => { + resetST(); + Implementation.reset(); + }); + + beforeEach(() => { + resetST(); + Implementation.reset(); + }); + + it("should get config successfully", async () => { + const { session } = await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/config`, { + method: "GET", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + }); + + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result).toHaveProperty("status", "OK"); + expect(result).toHaveProperty("config"); + expect(result.config).toHaveProperty("enableSettingPassword"); + expect(result.config).toHaveProperty("enableThirdPartyLinkning"); + expect(result.config).toHaveProperty("enableMfaConfiguration"); + }); + + it("should fail to get config without authentication", async () => { + await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/config`, { + method: "GET", + }); + + const result = await response.json(); + + expect(response.status).toBe(401); + expect(result).toHaveProperty("message", "unauthorised"); + }); + + it("should get user successfully", async () => { + const { user, session } = await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/user`, { + method: "GET", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + }); + + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result).toHaveProperty("status", "OK"); + expect(result).toHaveProperty("user"); + expect(result.user).toHaveProperty("id", user.id); + }); + + it("should fail to get user without authentication", async () => { + await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/user`, { + method: "GET", + }); + + const result = await response.json(); + + expect(response.status).toBe(401); + expect(result).toHaveProperty("message", "unauthorised"); + }); + + it("should set password successfully for user without existing password", async () => { + await setup({ + recipeList: [ + Session.init({}), + EmailPassword.init({}), + AccountLinking.init({ + shouldDoAutomaticAccountLinking: async () => { + return { + shouldAutomaticallyLink: true, + shouldRequireVerification: false, + }; + }, + }), + ThirdParty.init({ + signInAndUpFeature: { + providers: [ + { + config: { + thirdPartyId: "google", + clients: [ + { + clientId: "test", + clientSecret: "test", + }, + ], + }, + }, + ], + }, + }), + ], + }); + + const testEmail = getTestEmail(); + + const thirdPartyUser = await ThirdParty.manuallyCreateOrUpdateUser( + "public", + "google", + "test-user-id", + testEmail, + false, + ); + + if (thirdPartyUser.status !== "OK") { + throw new Error("Failed to create third party user"); + } + + const thirdPartySession = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(thirdPartyUser.user.id), + ); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/password/set`, { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartySession.getAccessToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: testEmail, + newPassword: "newPassword123", + }), + }); + + const result = await response.json(); + + expect(result).toHaveProperty("status", "OK"); + + const verifyCredentialsResponse = await verifyCredentials("public", testEmail, "newPassword123"); + expect(verifyCredentialsResponse.status).toBe("OK"); + }); + + it("should fail to set password for user with existing password", async () => { + const { user, session } = await setup(); + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/password/set`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: user.emails[0], + newPassword: "newPassword123", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(400); + expect(result).toHaveProperty("status", "ERROR"); + expect(result.message).toContain("already has a password set"); + }); + + it("should fail to set password without authentication", async () => { + const { user } = await setup(); + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/password/set`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: user.emails[0], + newPassword: "newPassword123", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(401); + expect(result).toHaveProperty("message", "unauthorised"); + }); + + it("should change password successfully", async () => { + const { user, session } = await setup(); + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/password/change`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + currentPassword: testPW, + newPassword: "newPassword123", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result).toHaveProperty("status", "OK"); + + const verifyCredentialsResponse = await verifyCredentials("public", user.emails[0], "newPassword123"); + expect(verifyCredentialsResponse.status).toBe("OK"); + }); + + it("should fail to change password with wrong current password", async () => { + const { session } = await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/password/change`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + currentPassword: "wrongPassword", + newPassword: "newPassword123", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(400); + expect(result).toHaveProperty("status", "ERROR"); + }); + + it("should fail to change password without authentication", async () => { + await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/password/change`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + currentPassword: testPW, + newPassword: "newPassword123", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(401); + expect(result).toHaveProperty("message", "unauthorised"); + }); + + it("should get MFA factors successfully", async () => { + const { session, user } = await setup({ + recipeList: [ + Session.init({ + override: { + functions: (oI) => { + return { + ...oI, + getGlobalClaimValidators: () => [], + }; + }, + }, + }), + EmailPassword.init({}), + MultiFactorAuth.init({ + firstFactors: ["emailpassword"], + }), + ], + }); + + await MultiFactorAuth.addToRequiredSecondaryFactorsForUser(user.id, "otp-phone"); + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/mfa`, { + method: "GET", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + }); + + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result).toHaveProperty("status", "OK"); + expect(result.requiredSecondaryFactors).toHaveLength(1); + expect(result.requiredSecondaryFactors).toContain("otp-phone"); + }); + + it("should fail to get MFA factors without authentication", async () => { + await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/mfa`, { + method: "GET", + }); + + const result = await response.json(); + + expect(response.status).toBe(401); + expect(result).toHaveProperty("message", "unauthorised"); + }); + + it("should set MFA factor as required successfully", async () => { + const { user, session } = await setup({ + recipeList: [ + Session.init({}), + EmailPassword.init({}), + MultiFactorAuth.init({ + firstFactors: ["emailpassword"], + }), + ], + }); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/mfa/set-required`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + factorId: "otp-phone", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result).toHaveProperty("status", "OK"); + + const requiredSecondaryFactors = await MultiFactorAuth.getRequiredSecondaryFactorsForUser(user.id); + expect(requiredSecondaryFactors).toContain("otp-phone"); + }); + + it("should fail to set MFA factor without authentication", async () => { + await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/mfa/set-required`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + factorId: "emailpassword", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(401); + expect(result).toHaveProperty("message", "unauthorised"); + }); + + it("should update OTP email successfully", async () => { + const { user, session } = await setup({ + recipeList: [ + Session.init({}), + EmailPassword.init({}), + AccountLinking.init({ + shouldDoAutomaticAccountLinking: async () => { + return { + shouldAutomaticallyLink: true, + shouldRequireVerification: false, + }; + }, + }), + MultiFactorAuth.init({ + firstFactors: ["emailpassword"], + }), + Passwordless.init({ + contactMethod: "EMAIL", + flowType: "USER_INPUT_CODE", + }), + ], + }); + + const signInUpResponse = await Passwordless.signInUp({ + email: "otheremail@test.com", + tenantId: "public", + session, + }); + expect(signInUpResponse.status).toBe("OK"); + expect((await getUser(user.id))?.emails).toContain("otheremail@test.com"); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/mfa/update-otp-email`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "newemail@test.com", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result).toHaveProperty("status"); + expect((await getUser(user.id))?.emails).toContain("newemail@test.com"); + }); + + it("should fail to update OTP email without authentication", async () => { + await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/mfa/update-otp-email`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "newemail@test.com", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(401); + expect(result).toHaveProperty("message", "unauthorised"); + }); + + it("should update TOTP device name successfully", async () => { + const { session, user } = await setup({ + recipeList: [ + Session.init({}), + EmailPassword.init({}), + MultiFactorAuth.init({ + firstFactors: ["emailpassword"], + }), + TOTP.init(), + ], + }); + + const oldDevice = await createDevice(user.id, "", "old-device-name"); + expect(oldDevice.status).toBe("OK"); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/mfa/update-totp`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "old-device-name", + newName: "new-device-name", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result).toHaveProperty("status", "OK"); + + const devices = await listDevices(user.id); + expect(devices.status).toBe("OK"); + expect(devices.devices).toHaveLength(1); + expect(devices.devices[0].name).toBe("new-device-name"); + }); + + it("should fail to update TOTP device name without authentication", async () => { + await setup(); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/mfa/update-totp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "old-device-name", + newName: "new-device-name", + }), + }); + + const result = await response.json(); + + expect(response.status).toBe(401); + expect(result).toHaveProperty("message", "unauthorised"); + }); + }); + + describe("exports", () => { + afterEach(() => { + resetST(); + Implementation.reset(); + }); + + beforeEach(() => { + resetST(); + Implementation.reset(); + }); + + it("should export init function", () => { + expect(init).toBeDefined(); + expect(typeof init).toBe("function"); + }); + + it("should initialize plugin with default config", async () => { + const { session } = await setup(); + const impl = Implementation.getInstanceOrThrow(); + + const config = await impl.getConfigForClient({ + session, + userContext: {}, + }); + + expect(config.status).toBe("OK"); + if (config.status === "OK") { + expect(config.config.enableSettingPassword).toBe(true); + expect(config.config.enableThirdPartyLinkning).toBe(true); + expect(config.config.enableMfaConfiguration).toBe(true); + } + }); + + it("should initialize plugin with custom config", async () => { + const { session } = await setup({ + pluginConfig: { + enableSettingPassword: false, + enableThirdPartyLinkning: false, + enableMfaConfiguration: false, + }, + }); + const impl = Implementation.getInstanceOrThrow(); + + const config = await impl.getConfigForClient({ + session, + userContext: {}, + }); + + expect(config.status).toBe("OK"); + if (config.status === "OK") { + expect(config.config.enableSettingPassword).toBe(false); + expect(config.config.enableThirdPartyLinkning).toBe(false); + expect(config.config.enableMfaConfiguration).toBe(false); + } + }); + }); +}); + +function resetST() { + ProcessState.getInstance().reset(); + SessionRaw.reset(); + UserRolesRaw.reset(); + EmailPasswordRaw.reset(); + ThirdPartyRaw.reset(); + AccountLinkingRaw.reset(); + MultitenancyRaw.reset(); + UserMetadataRaw.reset(); + MultiFactorAuthRaw.reset(); + PasswordlessRaw.reset(); + TOTPRaw.reset(); + SuperTokensRaw.reset(); +} + +async function setup(options?: { pluginConfig?: SuperTokensPluginProfileSecurityConfig; recipeList?: any[] }) { + let appId; + let isNewApp = false; + const coreBaseURL = process.env.CORE_BASE_URL || `http://localhost:3567`; + if (appId === undefined) { + isNewApp = true; + appId = crypto.randomUUID(); + const headers = { + "Content-Type": "application/json", + }; + if (process.env.CORE_API_KEY) { + headers["api-key"] = process.env.CORE_API_KEY; + } + await fetch(`${coreBaseURL}/recipe/multitenancy/app/v2`, { + method: "PUT", + headers, + body: JSON.stringify({ + appId: appId, + coreConfig: {}, + }), + }); + } + + const defaultRecipeList = [Session.init({}), EmailPassword.init({})]; + const recipeList = options?.recipeList || defaultRecipeList; + + SuperTokens.init({ + supertokens: { + connectionURI: `${coreBaseURL}/appid-${appId}`, + apiKey: process.env.CORE_API_KEY, + }, + appInfo: { + appName: "Test App", + apiDomain: `http://localhost:${testPORT}`, + websiteDomain: `http://localhost:${testPORT + 1}`, + }, + recipeList, + experimental: { + plugins: [init(options?.pluginConfig)], + }, + }); + + const app = express(); + app.use(middleware()); + app.get("/check-session", verifySession(), (req, res) => { + res.json({ + status: "OK", + }); + }); + app.use(errorHandler()); + + await new Promise((resolve) => { + app.listen(testPORT, () => resolve()); + }); + + const testEmail = getTestEmail(); + let user; + let session; + if (isNewApp) { + const signupResponse = await EmailPassword.signUp("public", testEmail, testPW); + if (signupResponse.status !== "OK") { + throw new Error("Failed to set up test user"); + } + user = signupResponse.user; + session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id), + ); + } else { + const userResponse = await SuperTokens.listUsersByAccountInfo("public", { + email: testEmail, + }); + user = userResponse[0]; + session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id), + ); + } + + return { + user, + session, + appId: appId, + }; +} diff --git a/packages/profile-security-nodejs/src/plugin.ts b/packages/profile-security-nodejs/src/plugin.ts index eceeb5a..1c93657 100644 --- a/packages/profile-security-nodejs/src/plugin.ts +++ b/packages/profile-security-nodejs/src/plugin.ts @@ -1,25 +1,28 @@ -import { getUser, isRecipeInitialized, deleteUser, getAvailableFirstFactors } from "supertokens-node"; -import { updateEmailOrPassword, verifyCredentials, signUp } from "supertokens-node/recipe/emailpassword"; -import MultiFactorAuth, { FactorIds } from "supertokens-node/recipe/multifactorauth"; -import AccountLinking from "supertokens-node/recipe/accountlinking"; -import TOTP from "supertokens-node/recipe/totp"; -import Passwordless from "supertokens-node/recipe/passwordless"; +import { isRecipeInitialized } from "supertokens-node"; import { SuperTokensPlugin } from "supertokens-node/types"; import { withRequestHandler } from "@shared/nodejs"; import { createPluginInitFunction } from "@shared/js"; -import { SuperTokensPluginProfileSecurityConfig } from "./types"; -import { PLUGIN_ID, HANDLE_BASE_PATH, PLUGIN_SDK_VERSION } from "./constants"; -import { enableDebugLogs, logDebugMessage } from "./logger"; +import { SuperTokensPluginProfileSecurityConfig, SuperTokensPluginProfileSecurityNormalisedConfig } from "./types"; +import { + PLUGIN_ID, + HANDLE_BASE_PATH, + PLUGIN_SDK_VERSION, + DEFAULT_ENABLE_SETTING_PASSWORD, + DEFAULT_ENABLE_THIRD_PARTY_LINKING, + DEFAULT_ENABLE_MFA_CONFIGURATION, +} from "./constants"; +import { enableDebugLogs } from "./logger"; +import { Implementation } from "./implementation"; export const init = createPluginInitFunction< SuperTokensPlugin, SuperTokensPluginProfileSecurityConfig, - never, - Required + Implementation, + SuperTokensPluginProfileSecurityNormalisedConfig >( - (pluginConfig) => { + (pluginConfig, implementation) => { return { id: PLUGIN_ID, compatibleSDKVersions: PLUGIN_SDK_VERSION, @@ -38,20 +41,16 @@ export const init = createPluginInitFunction< verifySessionOptions: { sessionRequired: true, }, - handler: withRequestHandler(async (req, res, session) => { + handler: withRequestHandler(async (req, res, session, userContext) => { const userId = session!.getUserId(); if (!userId) { return { status: "ERROR", message: "User not found" }; } - return { - status: "OK", - config: { - enableSettingPassword: pluginConfig.enableSettingPassword, - enableThirdPartyLinkning: pluginConfig.enableThirdPartyLinkning, - enableMfaConfiguration: pluginConfig.enableMfaConfiguration, - }, - }; + return implementation.getConfigForClient({ + session: session!, + userContext, + }); }), }, { @@ -81,67 +80,13 @@ export const init = createPluginInitFunction< return { status: "ERROR", message: "User not found" }; } - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - - // todo decide if we should allow setting password for other emails - if (!user.emails.includes(email)) { - return { - status: "ERROR", - message: "The user does not have this email address", - }; - } - - const passwordLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "emailpassword"); - if (passwordLoginMethods.length) { - return { - status: "ERROR", - message: - "User already has a password set. Please use the change password feature to update your password.", - }; - } - - // todo firgure out how to handle email verification - const signUpResult = await signUp( - session!.getTenantId(), + return implementation.setUserPassword({ + userId, email, - newPassword, - session!, // todo: ??? should we pass the session or try linking later? + password: newPassword, + session: session!, userContext, - ); - if (signUpResult.status === "EMAIL_ALREADY_EXISTS_ERROR") { - return { - status: "ERROR", - message: - "There already exists a user with this email address. Please use the change password feature to update your password.", - }; - } - if (signUpResult.status === "LINKING_TO_SESSION_USER_FAILED") { - return { - status: "ERROR", - message: "Could not link the new password to the user. Please contact support.", - }; - } - if (signUpResult.status !== "OK") { - return { - status: "ERROR", - message: "Password change failed", - }; - } - - const linkResp = await AccountLinking.linkAccounts(signUpResult.recipeUserId, session!.getUserId()); - if (linkResp.status !== "OK") { - logDebugMessage(`Could not link the new password to the user: ${linkResp.status}`); - - return { - status: "ERROR", - message: "Could not link the new password to the user. Please contact support.", - }; - } - - return { status: "OK" }; + }); }), }, { @@ -163,53 +108,15 @@ export const init = createPluginInitFunction< return { status: "ERROR", message: "User not found" }; } - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - const passwordLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "emailpassword"); - if (passwordLoginMethods.length === 0) { - return { - status: "ERROR", - message: "User has no password set. Please set a password first.", - }; - } - if (passwordLoginMethods.length > 1) { - return { - status: "ERROR", - message: "User has multiple password login methods. Please contact support.", - }; - } - - const passwordLoginMethod = passwordLoginMethods[0]!; - const { currentPassword, newPassword } = await req.getJSONBody(); - const verifyResult = await verifyCredentials( - session!.getTenantId(), - passwordLoginMethod.email!, + return implementation.changeUserPassword({ + userId, currentPassword, - ); - - if (verifyResult.status !== "OK") { - return { status: "ERROR", message: "Invalid password" }; - } - - const result = await updateEmailOrPassword({ - recipeUserId: passwordLoginMethod.recipeUserId, - password: newPassword, + newPassword, + session: session!, + userContext, }); - - if (result.status !== "OK") { - logDebugMessage(`Could not update password: ${result.status}`); - - return { - status: "ERROR", - message: "Password change failed", - }; - } - - return { status: "OK" }; }), }, { @@ -219,61 +126,25 @@ export const init = createPluginInitFunction< sessionRequired: true, }, handler: withRequestHandler(async (req, res, session, userContext) => { + if (!isRecipeInitialized("thirdparty")) { + return { + status: "ERROR", + message: "Unlinking account requires the ThirdParty recipe to be initialized", + }; + } const userId = session!.getUserId(); if (!userId) { return { status: "ERROR", message: "User not found" }; } - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - const { recipeUserId } = await req.getJSONBody(); - const availableFirstFactors = await getAvailableFirstFactors( - session!.getTenantId(), - session, - userContext, - ); - - const availableUserLoginMethods = user.loginMethods.filter( - (lm) => lm.recipeUserId.getAsString() !== recipeUserId, - ); - const availableUserFactorIds: string[] = availableUserLoginMethods - .map((lm) => { - if (lm.recipeId === "emailpassword") return [FactorIds.EMAILPASSWORD]; - if (lm.recipeId === "passwordless") { - if (lm.email) return [FactorIds.OTP_EMAIL, FactorIds.LINK_EMAIL]; - if (lm.phoneNumber) return [FactorIds.OTP_EMAIL, FactorIds.LINK_EMAIL]; - } - if (lm.recipeId === "thirdparty") return FactorIds.THIRDPARTY; - if (lm.recipeId === "webauthn") return [FactorIds.WEBAUTHN]; - - return undefined; - }) - .filter((factorIds) => factorIds !== undefined) - .flat(); - - const canUnlink = availableUserFactorIds.some((factorId) => availableFirstFactors.includes(factorId)); - if (!canUnlink) { - return { - status: "ERROR", - message: "User has no available first factor login methods", - }; - } - - const result = await deleteUser(recipeUserId, false, { + return implementation.unlinkThirdPartyUser({ + userId, + recipeUserId, + session: session!, userContext, }); - if (result.status !== "OK") { - return { - status: "ERROR", - message: "Could not unlink account", - }; - } - - return { status: "OK" }; }), }, { @@ -288,23 +159,14 @@ export const init = createPluginInitFunction< return { status: "ERROR", message: "User not found" }; } - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - const { factorId } = await req.getJSONBody(); - const requiredFactorIds = await MultiFactorAuth.getRequiredSecondaryFactorsForUser(userId); - for await (const factorId of requiredFactorIds) { - await MultiFactorAuth.removeFromRequiredSecondaryFactorsForUser(userId, factorId); - } - - if (factorId) { - await MultiFactorAuth.addToRequiredSecondaryFactorsForUser(userId, factorId); - } - - return { status: "OK" }; + return implementation.setOrRemoveSingleRequiredMfaFactorForUser({ + userId, + factorId, + session: session!, + userContext, + }); }), }, { @@ -319,17 +181,11 @@ export const init = createPluginInitFunction< return { status: "ERROR", message: "User not found" }; } - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - - const requiredSecondaryFactors = await MultiFactorAuth.getRequiredSecondaryFactorsForUser(user.id); - - return { - status: "OK", - requiredSecondaryFactors, - }; + return implementation.getRequiredSecondaryFactorsForUser({ + userId, + session: session!, + userContext, + }); }), }, @@ -344,46 +200,14 @@ export const init = createPluginInitFunction< if (!userId) { return { status: "ERROR", message: "User not found" }; } - - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - const { email } = await req.getJSONBody(); - const emailOtpLoginMethods = user.loginMethods.filter( - (lm) => lm.recipeId === "passwordless" && lm.email, - ); - if (emailOtpLoginMethods.length === 0) { - return { - status: "ERROR", - message: "User has no email OTP login method", - }; - } - if (emailOtpLoginMethods.length > 1) { - return { - status: "ERROR", - message: "User has multiple email OTP login methods", - }; - } - const emailOtpLoginMethod = emailOtpLoginMethods[0]!; - - const result = await Passwordless.updateUser({ - recipeUserId: emailOtpLoginMethod.recipeUserId, - email: email, + return implementation.changeOtpEmailForUser({ + userId, + email, + session: session!, + userContext, }); - - if (result.status !== "OK") { - return { - status: "ERROR", - message: "Failed to update email OTP login method", - }; - } - - return { - status: "OK", - }; }), }, { @@ -398,58 +222,14 @@ export const init = createPluginInitFunction< return { status: "ERROR", message: "User not found" }; } - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - - const phoneOtpLoginMethods = user.loginMethods.filter( - (lm) => lm.recipeId === "passwordless" && lm.phoneNumber, - ); - if (phoneOtpLoginMethods.length === 0) { - return { - status: "ERROR", - message: "User has no phone OTP login method", - }; - } - if (phoneOtpLoginMethods.length > 1) { - return { - status: "ERROR", - message: "User has multiple phone OTP login methods", - }; - } - const { phoneNumber } = await req.getJSONBody(); - const result = await Passwordless.createCode({ - phoneNumber, - tenantId: session!.getTenantId(), - session, - userContext, - }); - if (result.status !== "OK") { - return { - status: "ERROR", - message: "Failed to generate code", - }; - } - - await Passwordless.sendSms({ - isFirstFactor: false, - codeLifetime: 1000 * 60 * 5, // todo is this correct? + return implementation.sendSmsOtpForUserPhoneNumberChange({ + userId, phoneNumber, - preAuthSessionId: result.preAuthSessionId, - tenantId: session!.getTenantId(), + session: session!, userContext, - userInputCode: result.userInputCode, - type: "PASSWORDLESS_LOGIN", }); - - return { - status: "OK", - deviceId: result.deviceId, - preAuthSessionId: result.preAuthSessionId, - }; }), }, { @@ -464,78 +244,17 @@ export const init = createPluginInitFunction< return { status: "ERROR", message: "User not found" }; } - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - - const phoneOtpLoginMethods = user.loginMethods.filter( - (lm) => lm.recipeId === "passwordless" && lm.phoneNumber, - ); - if (phoneOtpLoginMethods.length === 0) { - return { - status: "ERROR", - message: "User has no phone OTP login method", - }; - } - if (phoneOtpLoginMethods.length > 1) { - return { - status: "ERROR", - message: "User has multiple phone OTP login methods", - }; - } - const phoneOtpLoginMethod = phoneOtpLoginMethods[0]!; - const { phoneNumber, code, deviceId, preAuthSessionId } = await req.getJSONBody(); - const checkResult = await Passwordless.checkCode({ + return implementation.changeOtpPhoneNumberForUser({ + userId, + phoneNumber, deviceId, preAuthSessionId, - userInputCode: code, - tenantId: session!.getTenantId(), - userContext, - }); - - if (checkResult.status !== "OK") { - return { - status: "ERROR", - message: "Failed to validate code", - }; - } - if (!checkResult.consumedDevice.phoneNumber) { - return { - status: "ERROR", - message: "Failed to validate code", - }; - } - if (checkResult.consumedDevice.phoneNumber !== phoneNumber) { - return { - status: "ERROR", - message: "Code is not valid for this phone number", - }; - } - - const updateResult = await Passwordless.updateUser({ - recipeUserId: phoneOtpLoginMethod.recipeUserId, - phoneNumber: checkResult.consumedDevice.phoneNumber, - }); - if (updateResult.status !== "OK") { - return { - status: "ERROR", - message: "Failed to update phone OTP login method", - }; - } - - // doesn't matter if it fails or not, since the code we'll revoke itself after a specific time - await Passwordless.revokeAllCodes({ - phoneNumber: checkResult.consumedDevice.phoneNumber, - tenantId: session!.getTenantId(), + code, + session: session!, userContext, }); - - return { - status: "OK", - }; }), }, { @@ -550,25 +269,15 @@ export const init = createPluginInitFunction< return { status: "ERROR", message: "User not found" }; } - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - const { name, newName } = await req.getJSONBody(); - const result = await TOTP.updateDevice(userId, name, newName, userContext); - - if (result.status !== "OK") { - return { - status: "ERROR", - message: "Could not update TOTP device", - }; - } - - return { - status: "OK", - }; + return implementation.renameOtpTotpDeviceForUser({ + userId, + name, + newName, + session: session!, + userContext, + }); }), }, { @@ -583,15 +292,11 @@ export const init = createPluginInitFunction< return { status: "ERROR", message: "User not found" }; } - const user = await getUser(userId, userContext); - if (!user) { - return { status: "ERROR", message: "User not found" }; - } - - return { - status: "OK", - user: user.toJson(), - }; + return implementation.getUser({ + userId, + session: session!, + userContext, + }); }), }, ], @@ -599,12 +304,12 @@ export const init = createPluginInitFunction< }, }; }, - undefined, + (config) => Implementation.init(config), (pluginConfig) => { return { - enableSettingPassword: pluginConfig.enableSettingPassword ?? true, - enableThirdPartyLinkning: pluginConfig.enableThirdPartyLinkning ?? true, - enableMfaConfiguration: pluginConfig.enableMfaConfiguration ?? true, + enableSettingPassword: pluginConfig.enableSettingPassword ?? DEFAULT_ENABLE_SETTING_PASSWORD, + enableThirdPartyLinkning: pluginConfig.enableThirdPartyLinkning ?? DEFAULT_ENABLE_THIRD_PARTY_LINKING, + enableMfaConfiguration: pluginConfig.enableMfaConfiguration ?? DEFAULT_ENABLE_MFA_CONFIGURATION, }; }, ); diff --git a/packages/profile-security-nodejs/src/types.ts b/packages/profile-security-nodejs/src/types.ts index 6a348e4..86725db 100644 --- a/packages/profile-security-nodejs/src/types.ts +++ b/packages/profile-security-nodejs/src/types.ts @@ -3,3 +3,5 @@ export type SuperTokensPluginProfileSecurityConfig = { enableThirdPartyLinkning?: boolean; enableMfaConfiguration?: boolean; }; + +export type SuperTokensPluginProfileSecurityNormalisedConfig = Required; diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx index e440e16..d69f08e 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx @@ -86,6 +86,10 @@ export const MfaFactorPhoneOtpConfig = ({ user, onSuccess }: { user: User; onSuc if (selectedPhoneNumber === currentPhoneNumber) { return; } + if (!codeDetails) { + return; + } + const res = await api.updateMfaOtpPhoneNumber({ phoneNumber: selectedPhoneNumber, code, @@ -98,7 +102,7 @@ export const MfaFactorPhoneOtpConfig = ({ user, onSuccess }: { user: User; onSuc resetCode(); }, - [currentPhoneNumber, api, selectedPhoneNumber], + [currentPhoneNumber, api, selectedPhoneNumber, codeDetails, code], { successMessage: t("PL_SEC_MFA_CHANGE_PHONE_NUMBER_SUCCESS_CHANGE"), errorMessage: t("PL_SEC_MFA_CHANGE_PHONE_NUMBER_ERROR_CHANGE"), From 0c2a1f647740dd61cff263c37d88ee351851b705 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 16 Oct 2025 13:44:56 +0300 Subject: [PATCH 07/11] cleanup list card component --- .../src/components/list-card/list-card.tsx | 79 ++----- .../security-section/mfa-factor-totp.tsx | 202 +++++++++--------- .../security-section/webauthn-section.tsx | 91 ++++---- 3 files changed, 171 insertions(+), 201 deletions(-) diff --git a/packages/profile-security-react/src/components/list-card/list-card.tsx b/packages/profile-security-react/src/components/list-card/list-card.tsx index 3daee5f..e94201d 100644 --- a/packages/profile-security-react/src/components/list-card/list-card.tsx +++ b/packages/profile-security-react/src/components/list-card/list-card.tsx @@ -6,47 +6,27 @@ import style from "./list-card.module.css"; const cx = classNames.bind(style); -export const ListCard = ({ title, children }: { title?: string; children: any }) => { - const footerChild = useMemo(() => { - if (!children) { - return undefined; - } - - if (children?.type === ListCardFooter) { - return children; - } - - if (Array.isArray(children)) { - return children.find((child) => child?.type === ListCardFooter); - } - - return undefined; - }, [children]); - - const restChildren = useMemo(() => { - if (Array.isArray(children)) { - return children.filter((child) => child?.type !== ListCardFooter); - } - - if (children?.type !== ListCardFooter) { - return children; - } - - return []; - }, [children]); - +export const ListCard = ({ + title, + children, + FooterComponent, +}: { + title?: string; + children: any; + FooterComponent?: React.ReactElement; +}) => { return ( - {Boolean(restChildren.length) && ( + {children.length && (
- {restChildren} + {children}
)} - {footerChild} + {FooterComponent}
); }; @@ -59,34 +39,17 @@ export const ListCardItemActions = ({ children }: { children: React.ReactNode }) return
{children}
; }; -export const ListCardItem = ({ children }: { children: any }) => { - const actionsChild = useMemo(() => { - if (children?.type === ListCardItemActions) { - return children; - } - - if (Array.isArray(children)) { - return children.find((child) => child?.type === ListCardItemActions); - } - - return undefined; - }, [children]); - - const contentChildren = useMemo(() => { - if (Array.isArray(children)) { - return children.filter((child) => child?.type !== ListCardItemActions); - } - if (children?.type !== ListCardItemActions) { - return children; - } - - return undefined; - }, [children]); - +export const ListCardItem = ({ + children, + ActionsComponent, +}: { + children: any; + ActionsComponent?: React.ReactElement; +}) => { return (
- {contentChildren} - {actionsChild} + {children} + {ActionsComponent}
); }; diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx index 7e55964..bb5a163 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx @@ -160,9 +160,110 @@ export const MfaFactorTotpList = ({ user, onSuccess }: { user: User; onSuccess: return (
- + + {!newTotpDevice && ( + <> +
+ {t("PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_NAME_LABEL")} +
+ + + + + + )} + + {newTotpDevice && ( + <> +

+ {t("PL_SEC_MFA_TOTP_SETUP_VERIFY_DESCRIPTION")} +

+ + {newTotpDevice?.qrString && ( +
+ +
+ )} + + setTotpVerificationCode(value as string)} + /> + + + + )} + + }> {totpDevices.map((totp) => ( - + + {isRenaming(totp.name) && ( + <> + + + + + )} + + {!isRenaming(totp.name) && ( + <> + + + + + )} + + }> {!isRenaming(totp.name) && {totp.name}} {isRenaming(totp.name) && ( @@ -181,105 +282,8 @@ export const MfaFactorTotpList = ({ user, onSuccess }: { user: User; onSuccess: /> )} - - - {isRenaming(totp.name) && ( - <> - - - - - )} - - {!isRenaming(totp.name) && ( - <> - - - - - )} - ))} - - - {!newTotpDevice && ( - <> -
- {t("PL_SEC_MFA_TOTP_SETUP_ADD_DEVICE_NAME_LABEL")} -
- - - - - - )} - - {newTotpDevice && ( - <> -

- {t("PL_SEC_MFA_TOTP_SETUP_VERIFY_DESCRIPTION")} -

- - {newTotpDevice?.qrString && ( -
- -
- )} - - setTotpVerificationCode(value as string)} - /> - - - - )} -
); diff --git a/packages/profile-security-react/src/components/security-section/webauthn-section.tsx b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx index 8df15b6..1ae6d62 100644 --- a/packages/profile-security-react/src/components/security-section/webauthn-section.tsx +++ b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx @@ -132,57 +132,60 @@ export const WebauthnSection = ({ return (
- + +
+ {t("PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL")} +
+ + { + if (!value) { + return; + } + setWebauthnEmail(value as string); + }} + options={webAuthnEmails.map((email) => ({ label: email, value: email })) ?? []} + disabled={webAuthnEmails.length <= 1} + /> + + + + }> {credentials.map((credential, index) => ( - + + + + }> {webauthnEmail} {new Date(credential.createdAt).toLocaleString()} - - - - ))} - - -
- {t("PL_SEC_WEBAUTHN_SELECT_EMAIL_LABEL")} -
- - { - if (!value) { - return; - } - setWebauthnEmail(value as string); - }} - options={webAuthnEmails.map((email) => ({ label: email, value: email })) ?? []} - disabled={webAuthnEmails.length <= 1} - /> - - -
); From 9f02de360a5776ab7dde67ade88f55018f2c9ac1 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 16 Oct 2025 15:13:39 +0300 Subject: [PATCH 08/11] pr fixes --- .../security-section/mfa-factor-email-otp.tsx | 5 ++- .../security-section/mfa-factor-phone-otp.tsx | 5 ++- .../security-section/mfa-factor-totp.tsx | 3 +- .../security-section/mfa-section.tsx | 43 +++++++++++-------- .../security-section/security-section.tsx | 8 ++-- .../security-section/set-webauthn-section.tsx | 1 + .../security-section/webauthn-section.tsx | 25 +++++------ 7 files changed, 50 insertions(+), 40 deletions(-) diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx index 7d00b49..0641ab8 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx @@ -4,6 +4,7 @@ import { useState, useMemo } from "react"; import { consumeCode, createCode } from "supertokens-auth-react/recipe/passwordless/index.js"; import { User } from "supertokens-web-js/types"; +import { logDebugMessage } from "../../logger"; import { usePluginContext } from "../../plugin"; import { FormActions } from "../form-actions"; import { FormRow } from "../form-item"; @@ -18,11 +19,11 @@ export const MfaFactorEmailOtpConfig = ({ user, onSuccess }: { user: User; onSuc const loginMethod = useMemo(() => { const loginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.email); if (loginMethods.length === 0) { - console.warn("User has no email OTP login method"); + logDebugMessage("User has no email OTP login method"); return null; } if (loginMethods.length > 1) { - console.warn("User has multiple email OTP login methods"); + logDebugMessage("User has multiple email OTP login methods"); return null; } diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx index d69f08e..81bf870 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx @@ -4,6 +4,7 @@ import { useState, useMemo, useCallback } from "react"; import { consumeCode, createCode } from "supertokens-auth-react/recipe/passwordless/index.js"; import { User } from "supertokens-web-js/types"; +import { logDebugMessage } from "../../logger"; import { usePluginContext } from "../../plugin"; import { FormActions } from "../form-actions"; import { FormRow } from "../form-item"; @@ -25,11 +26,11 @@ export const MfaFactorPhoneOtpConfig = ({ user, onSuccess }: { user: User; onSuc const loginMethod = useMemo(() => { const loginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.phoneNumber); if (loginMethods.length === 0) { - console.warn("User has no phone OTP login method"); + logDebugMessage("User has no phone OTP login method"); return null; } if (loginMethods.length > 1) { - console.warn("User has multiple phone OTP login methods"); + logDebugMessage("User has multiple phone OTP login methods"); return null; } diff --git a/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx b/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx index bb5a163..12de59b 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx @@ -151,8 +151,7 @@ export const MfaFactorTotpList = ({ user, onSuccess }: { user: User; onSuccess: }, ); - const isRenaming = (totpName: string) => - Boolean(activeTotpDevice) && activeTotpDevice?.action === "rename" && activeTotpDevice.name === totpName; + const isRenaming = (totpName: string) => activeTotpDevice?.action === "rename" && activeTotpDevice?.name === totpName; useEffect(() => { loadTotps(); diff --git a/packages/profile-security-react/src/components/security-section/mfa-section.tsx b/packages/profile-security-react/src/components/security-section/mfa-section.tsx index 2879221..752faf9 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-section.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-section.tsx @@ -46,16 +46,6 @@ export const MfaSection = ({ description: string; setup: boolean; required: boolean; - ManageComponent: { - Config: React.ComponentType<{ - user: User; - onSuccess: () => Promise; - }>; - Setup: React.ComponentType<{ - user: User; - onSuccess: () => Promise; - }>; - } | null; }[] >([]); @@ -90,7 +80,6 @@ export const MfaSection = ({ // make sure that the factor is already setup and the claim is set so we don't trigger a redirect to the factor login screen setup: mfaInfo.factors.alreadySetup.includes(factor.id) && Boolean(mfaClaimValue?.c[factor.id]), required: res.requiredSecondaryFactors.includes(factor.id), - ManageComponent: manageFactorComponents[factor.id as keyof typeof manageFactorComponents] ?? null, })); setSecondaryFactors(secondaryFactors); @@ -131,6 +120,31 @@ export const MfaSection = ({ await loadMfaInfo(); }, [loadMfaInfo, onSuccess]); + const getManageFactorComponent = useCallback( + (factor: (typeof secondaryFactors)[number]) => { + const FactorManageComponent = manageFactorComponents[factor.id as keyof typeof manageFactorComponents] ?? null; + if (!FactorManageComponent) { + return null; + } + + let type: "config" | "setup"; + if (factorBeingSetup === factor.id) { + type = "setup"; + } else if (!factorBeingSetup && factor.required && factor.setup) { + type = "config"; + } else { + return null; + } + + return type === "config" ? ( + + ) : ( + + ); + }, + [user, _onSuccess, factorBeingSetup], + ); + useEffect(() => { if (isLoaded) { return; @@ -194,12 +208,7 @@ export const MfaSection = ({
- {factor.ManageComponent && factorBeingSetup === factor.id && ( - - )} - {factor.ManageComponent && !factorBeingSetup && factor.required && factor.setup && ( - - )} + {getManageFactorComponent(factor)}
))}
diff --git a/packages/profile-security-react/src/components/security-section/security-section.tsx b/packages/profile-security-react/src/components/security-section/security-section.tsx index 81f3f14..0817b5b 100644 --- a/packages/profile-security-react/src/components/security-section/security-section.tsx +++ b/packages/profile-security-react/src/components/security-section/security-section.tsx @@ -75,11 +75,9 @@ export const SecurityDetailsSection = () => { return; } - loadConfig() - .then(() => loadUserInfo()) - .then(() => { - setIsLoaded(true); - }); + Promise.all([loadConfig(), loadUserInfo()]).then(() => { + setIsLoaded(true); + }); }, [isLoaded, loadConfig, loadUserInfo]); return ( diff --git a/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx b/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx index 2b1bb5d..71785b4 100644 --- a/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx +++ b/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx @@ -31,6 +31,7 @@ export const SetWebAuthnSection = ({ await registerCredentialWithSignUp({ email: webauthnSetEmail, userContext: {}, + shouldTryLinkingWithSessionUser: true, }); }, [webauthnSetEmail], diff --git a/packages/profile-security-react/src/components/security-section/webauthn-section.tsx b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx index 1ae6d62..06913d5 100644 --- a/packages/profile-security-react/src/components/security-section/webauthn-section.tsx +++ b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx @@ -14,6 +14,7 @@ import { ListCard, ListCardFooter, ListCardItem, ListCardItemActions } from "../ import style from "./security-section.module.css"; const cx = classNames.bind(style); + export const WebauthnSection = ({ user, isLoading, @@ -47,14 +48,14 @@ export const WebauthnSection = ({ setWebauthnEmail(email); } - loadWebAuthn(); + loadCredentialsAction(); }, [user]); const webAuthnEmails = useMemo(() => { return user?.loginMethods.filter((lm: any) => lm.recipeId === "webauthn").map((lm: any) => lm.email) ?? []; }, [user]); - const loadWebAuthn = usePrettyAction( + const loadCredentialsAction = usePrettyAction( async () => { const result = await listCredentials({ userContext: {} }); if (result.status === "OK") { @@ -70,12 +71,12 @@ export const WebauthnSection = ({ }, ); - const _onSuccess = useCallback(async () => { - await loadWebAuthn(); + const onActionSuccess = useCallback(async () => { + await loadCredentialsAction(); await onSuccess(); - }, [onSuccess]); + }, [onSuccess, loadCredentialsAction]); - const _removeCredential = usePrettyAction( + const removeCredentialAction = usePrettyAction( async (webauthnCredentialId: string) => { const result = await removeCredential({ webauthnCredentialId, @@ -89,13 +90,13 @@ export const WebauthnSection = ({ }, [], { - onSuccess: _onSuccess, + onSuccess: onActionSuccess, errorMessage: t("PL_SEC_WEBAUTHN_ERROR_REMOVE_CREDENTIAL"), setLoading: setIsLoading, }, ); - const addCredential = usePrettyAction( + const addCredentialAction = usePrettyAction( async () => { // assume only one webauthn user const recipeUserId = user.loginMethods.find( @@ -115,14 +116,14 @@ export const WebauthnSection = ({ throw new Error("Failed to add Passkey"); } - await loadWebAuthn(); + await loadCredentialsAction(); }, [webauthnEmail, user], { errorMessage: t("PL_SEC_WEBAUTHN_ERROR_ADD_CREDENTIAL"), successMessage: t("PL_SEC_WEBAUTHN_SUCCESS_ADD_CREDENTIAL"), setLoading: setIsLoading, - onSuccess: _onSuccess, + onSuccess: onActionSuccess, }, ); @@ -155,7 +156,7 @@ export const WebauthnSection = ({ From 5558accddce52f111819dbe016c24e2a88eccdee Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 16 Oct 2025 17:16:31 +0300 Subject: [PATCH 09/11] pr review fixes --- .../src/implementation.ts | 37 +++++-------------- .../profile-security-nodejs/src/plugin.ts | 4 +- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/packages/profile-security-nodejs/src/implementation.ts b/packages/profile-security-nodejs/src/implementation.ts index 489cf67..5139b99 100644 --- a/packages/profile-security-nodejs/src/implementation.ts +++ b/packages/profile-security-nodejs/src/implementation.ts @@ -78,30 +78,23 @@ export class Implementation { return { status: "ERROR", message: "User not found" }; } - // todo decide if we should allow setting password for other emails - if (!user.emails.includes(email)) { + const passwordLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "emailpassword"); + if (passwordLoginMethods.length) { return { status: "ERROR", - message: "The user does not have this email address", + message: "User already has a password set. Please use the change password feature to update your password.", }; } - const passwordLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "emailpassword"); - if (passwordLoginMethods.length) { + const verifiedLoginMethod = user.loginMethods.find((lm) => lm.email === email && lm.verified); + if (!verifiedLoginMethod) { return { status: "ERROR", - message: "User already has a password set. Please use the change password feature to update your password.", + message: "The user does not have this email address or is not verified", }; } - // todo firgure out how to handle email verification - const signUpResult = await signUp( - session!.getTenantId(), - email, - password, - session!, // todo: ??? should we pass the session or try linking later? - userContext, - ); + const signUpResult = await signUp(session!.getTenantId(), email, password, session!, userContext); if (signUpResult.status === "EMAIL_ALREADY_EXISTS_ERROR") { return { status: "ERROR", @@ -118,17 +111,7 @@ export class Implementation { if (signUpResult.status !== "OK") { return { status: "ERROR", - message: "Password change failed", - }; - } - - const linkResp = await AccountLinking.linkAccounts(signUpResult.recipeUserId, session!.getUserId(), userContext); - if (linkResp.status !== "OK") { - logDebugMessage(`Could not link the new password to the user: ${linkResp.status}`); - - return { - status: "ERROR", - message: "Could not link the new password to the user. Please contact support.", + message: "Password setting failed. Please contact support.", }; } @@ -215,7 +198,7 @@ export class Implementation { return { status: "OK" }; }; - unlinkThirdPartyUser = async function ( + unlinkAndRemoveRecipeUser = async function ( this: Implementation, { userId, @@ -273,7 +256,7 @@ export class Implementation { return { status: "OK" }; }; - setOrRemoveSingleRequiredMfaFactorForUser = async function ( + toggleSingleRequiredMfaFactorForUser = async function ( this: Implementation, { userId, diff --git a/packages/profile-security-nodejs/src/plugin.ts b/packages/profile-security-nodejs/src/plugin.ts index 1c93657..d69c6f5 100644 --- a/packages/profile-security-nodejs/src/plugin.ts +++ b/packages/profile-security-nodejs/src/plugin.ts @@ -139,7 +139,7 @@ export const init = createPluginInitFunction< const { recipeUserId } = await req.getJSONBody(); - return implementation.unlinkThirdPartyUser({ + return implementation.unlinkAndRemoveRecipeUser({ userId, recipeUserId, session: session!, @@ -161,7 +161,7 @@ export const init = createPluginInitFunction< const { factorId } = await req.getJSONBody(); - return implementation.setOrRemoveSingleRequiredMfaFactorForUser({ + return implementation.toggleSingleRequiredMfaFactorForUser({ userId, factorId, session: session!, From 5d1c5c722f0156260eeb0e0794615a436ee1e7cb Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 16 Oct 2025 17:21:50 +0300 Subject: [PATCH 10/11] cleanup --- packages/profile-security-nodejs/src/implementation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/profile-security-nodejs/src/implementation.ts b/packages/profile-security-nodejs/src/implementation.ts index 5139b99..18e678b 100644 --- a/packages/profile-security-nodejs/src/implementation.ts +++ b/packages/profile-security-nodejs/src/implementation.ts @@ -1,6 +1,5 @@ import { getUser, deleteUser, getAvailableFirstFactors, User } from "supertokens-node"; import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; -import AccountLinking from "supertokens-node/recipe/accountlinking"; import { FactorIds } from "supertokens-node/recipe/multifactorauth"; import { signUp, updateEmailOrPassword, verifyCredentials } from "supertokens-node/recipe/emailpassword"; import { BaseFormSection } from "@supertokens-plugins/profile-details-shared"; From 2c5f862d96ac594e016c46909733c6bb376f7b22 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 20 Oct 2025 12:22:40 +0300 Subject: [PATCH 11/11] pr fixes --- package-lock.json | 195 ++++++++++++++++++ .../src/implementation.ts | 36 +--- .../profile-security-nodejs/src/plugin.ts | 6 +- .../security-section/mfa-section.tsx | 47 ++--- 4 files changed, 231 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a42d7b..9c8dd01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2966,6 +2966,14 @@ "resolved": "packages/profile-details-shared", "link": true }, + "node_modules/@supertokens-plugins/profile-security-nodejs": { + "resolved": "packages/profile-security-nodejs", + "link": true + }, + "node_modules/@supertokens-plugins/profile-security-react": { + "resolved": "packages/profile-security-react", + "link": true + }, "node_modules/@supertokens-plugins/progressive-profiling-nodejs": { "resolved": "packages/progressive-profiling-nodejs", "link": true @@ -17717,6 +17725,193 @@ "@shared/tsconfig": "*" } }, + "packages/profile-security-nodejs": { + "name": "@supertokens-plugins/profile-security-nodejs", + "version": "0.0.2-beta.2", + "devDependencies": { + "@shared/eslint": "*", + "@shared/nodejs": "*", + "@shared/tsconfig": "*", + "express": "^5.1.0", + "prettier": "2.0.5", + "pretty-quick": "^3.1.1", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "supertokens-node": ">=23.0.0" + } + }, + "packages/profile-security-nodejs/node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "packages/profile-security-nodejs/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/profile-security-nodejs/node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, + "packages/profile-security-nodejs/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "packages/profile-security-nodejs/node_modules/picomatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/profile-security-nodejs/node_modules/prettier": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz", + "integrity": "sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "packages/profile-security-nodejs/node_modules/pretty-quick": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/pretty-quick/-/pretty-quick-3.3.1.tgz", + "integrity": "sha512-3b36UXfYQ+IXXqex6mCca89jC8u0mYLqFAN5eTQKoXO6oCQYcIVYZEB/5AlBHI7JPYygReM2Vv6Vom/Gln7fBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^4.1.0", + "find-up": "^4.1.0", + "ignore": "^5.3.0", + "mri": "^1.2.0", + "picocolors": "^1.0.0", + "picomatch": "^3.0.1", + "tslib": "^2.6.2" + }, + "bin": { + "pretty-quick": "dist/cli.js" + }, + "engines": { + "node": ">=10.13" + }, + "peerDependencies": { + "prettier": "^2.0.0" + } + }, + "packages/profile-security-nodejs/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "packages/profile-security-react": { + "name": "@supertokens-plugins/profile-security-react", + "version": "0.0.1-beta.1", + "dependencies": { + "@shared/js": "*", + "@shared/react": "*", + "@shared/ui": "*", + "supertokens-js-override": "^0.0.4" + }, + "devDependencies": { + "@shared/eslint": "*", + "@shared/tsconfig": "*", + "@types/react": "^17.0.20", + "prettier": "3.5.3", + "pretty-quick": "^4.2.2", + "typescript": "^5.8.3", + "vite-plugin-css-injected-by-js": "^3.5.2" + }, + "peerDependencies": { + "react": ">=18.3.1", + "react-dom": ">=18.3.1", + "supertokens-auth-react": ">=0.50.0" + } + }, + "packages/profile-security-react/node_modules/@types/react": { + "version": "17.0.89", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.89.tgz", + "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.0.2" + } + }, + "packages/profile-security-react/node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "packages/progressive-profiling-nodejs": { "name": "@supertokens-plugins/progressive-profiling-nodejs", "version": "0.1.1", diff --git a/packages/profile-security-nodejs/src/implementation.ts b/packages/profile-security-nodejs/src/implementation.ts index 18e678b..5c81173 100644 --- a/packages/profile-security-nodejs/src/implementation.ts +++ b/packages/profile-security-nodejs/src/implementation.ts @@ -40,9 +40,9 @@ export class Implementation { getConfigForClient = async function ( this: Implementation, - // props needed for overriding + // input needed for overriding // eslint-disable-next-line @typescript-eslint/no-unused-vars - props: { userContext: any; session: SessionContainerInterface }, + input: { userContext: any; session: SessionContainerInterface }, ): Promise< { status: "OK"; config: SuperTokensPluginProfileSecurityNormalisedConfig } | { status: "ERROR"; message: string } > { @@ -107,11 +107,11 @@ export class Implementation { message: "Could not link the new password to the user. Please contact support.", }; } + // handled all the error cases in the previous if statements, so this should not happen + // it is here just to make sure that if new error cases are added, they will be handled if (signUpResult.status !== "OK") { - return { - status: "ERROR", - message: "Password setting failed. Please contact support.", - }; + logDebugMessage(`Should not have come here. Could not sign up because of: ${JSON.stringify(signUpResult)}`); + return { status: "ERROR", message: "Password setting failed. Please contact support." }; } return { status: "OK" }; @@ -261,7 +261,7 @@ export class Implementation { userId, factorId, userContext, - }: { userId: string; factorId?: string; session: SessionContainerInterface; userContext: any }, + }: { userId: string; factorId: string; session: SessionContainerInterface; userContext: any }, ): Promise<{ status: "OK" } | { status: "ERROR"; message: string }> { const user = await getUser(userId, userContext); if (!user) { @@ -269,11 +269,9 @@ export class Implementation { } const requiredFactorIds = await MultiFactorAuth.getRequiredSecondaryFactorsForUser(userId, userContext); - for await (const factorId of requiredFactorIds) { + if (requiredFactorIds.includes(factorId)) { await MultiFactorAuth.removeFromRequiredSecondaryFactorsForUser(userId, factorId, userContext); - } - - if (factorId) { + } else { await MultiFactorAuth.addToRequiredSecondaryFactorsForUser(userId, factorId, userContext); } @@ -302,7 +300,6 @@ export class Implementation { return { status: "OK", loginMethod: emailOtpLoginMethods[0]! }; }; - // todo email validation? changeOtpEmailForUser = async function ( this: Implementation, { @@ -317,19 +314,6 @@ export class Implementation { return { status: "ERROR", message: "User not found" }; } - const emailOtpLoginMethods = user.loginMethods.filter((lm) => lm.recipeId === "passwordless" && lm.email); - if (emailOtpLoginMethods.length === 0) { - return { - status: "ERROR", - message: "User has no email OTP login method", - }; - } - if (emailOtpLoginMethods.length > 1) { - return { - status: "ERROR", - message: "User has multiple email OTP login methods", - }; - } const loginMethodSelectResult = await this.selectLoginMethodForOtpEmailChange({ user, session, userContext }); if (loginMethodSelectResult.status !== "OK") { return loginMethodSelectResult; @@ -528,7 +512,7 @@ export class Implementation { }; }; - renameOtpTotpDeviceForUser = async function ( + renameTotpDeviceForUser = async function ( this: Implementation, { userId, diff --git a/packages/profile-security-nodejs/src/plugin.ts b/packages/profile-security-nodejs/src/plugin.ts index d69c6f5..cd4f3ce 100644 --- a/packages/profile-security-nodejs/src/plugin.ts +++ b/packages/profile-security-nodejs/src/plugin.ts @@ -161,6 +161,10 @@ export const init = createPluginInitFunction< const { factorId } = await req.getJSONBody(); + if (!factorId) { + return { status: "ERROR", message: "The factorId parameter is required" }; + } + return implementation.toggleSingleRequiredMfaFactorForUser({ userId, factorId, @@ -271,7 +275,7 @@ export const init = createPluginInitFunction< const { name, newName } = await req.getJSONBody(); - return implementation.renameOtpTotpDeviceForUser({ + return implementation.renameTotpDeviceForUser({ userId, name, newName, diff --git a/packages/profile-security-react/src/components/security-section/mfa-section.tsx b/packages/profile-security-react/src/components/security-section/mfa-section.tsx index 752faf9..340eae9 100644 --- a/packages/profile-security-react/src/components/security-section/mfa-section.tsx +++ b/packages/profile-security-react/src/components/security-section/mfa-section.tsx @@ -1,4 +1,4 @@ -import { Button, Tag, ToggleInput, usePrettyAction, useToast } from "@shared/ui"; +import { Button, ToggleInput, usePrettyAction } from "@shared/ui"; import classNames from "classnames/bind"; import { useCallback, useEffect, useState } from "react"; import { @@ -44,13 +44,12 @@ export const MfaSection = ({ id: string; name: string; description: string; - setup: boolean; - required: boolean; + isSetup: boolean; + isRequired: boolean; }[] >([]); const { api, t } = usePluginContext(); - const { addToast } = useToast(); const loadMfaInfo = usePrettyAction( async () => { @@ -68,19 +67,17 @@ export const MfaSection = ({ claim: MultiFactorAuthClaim, }); - const secondaryFactors = getSecondaryFactors({}) - .filter( - (factor) => - mfaInfo.factors.alreadySetup.includes(factor.id) || mfaInfo.factors.allowedToSetup.includes(factor.id), - ) - .map((factor) => ({ - id: factor.id, - name: factor.name, - description: factor.description, - // make sure that the factor is already setup and the claim is set so we don't trigger a redirect to the factor login screen - setup: mfaInfo.factors.alreadySetup.includes(factor.id) && Boolean(mfaClaimValue?.c[factor.id]), - required: res.requiredSecondaryFactors.includes(factor.id), - })); + const rawSecondaryFactors = getSecondaryFactors({}).filter( + (factor) => + mfaInfo.factors.alreadySetup.includes(factor.id) || mfaInfo.factors.allowedToSetup.includes(factor.id), + ); + const secondaryFactors = rawSecondaryFactors.map((factor) => ({ + id: factor.id, + name: factor.name, + description: factor.description, + isSetup: mfaInfo.factors.alreadySetup.includes(factor.id) && Boolean(mfaClaimValue?.c[factor.id]), + isRequired: res.requiredSecondaryFactors.includes(factor.id), + })); setSecondaryFactors(secondaryFactors); return { @@ -96,15 +93,13 @@ export const MfaSection = ({ const toggleSecondaryFactor = usePrettyAction( async (factorId: string) => { - const required = secondaryFactors.find((f) => f.id === factorId)?.required; - const payload = required ? undefined : factorId; - const res = await api.setRequiredSecondaryFactor(payload); + const res = await api.setRequiredSecondaryFactor(factorId); if (res.status !== "OK") { throw new Error(res.message); } }, - [secondaryFactors, addToast], + [], { errorMessage: t("PL_SEC_MFA_ERROR_TOGGLE_SECONDARY_FACTOR"), successMessage: t("PL_SEC_MFA_SUCCESS_TOGGLE_SECONDARY_FACTOR"), @@ -130,7 +125,7 @@ export const MfaSection = ({ let type: "config" | "setup"; if (factorBeingSetup === factor.id) { type = "setup"; - } else if (!factorBeingSetup && factor.required && factor.setup) { + } else if (!factorBeingSetup && factor.isRequired && factor.isSetup) { type = "config"; } else { return null; @@ -173,10 +168,10 @@ export const MfaSection = ({
- {factor.setup && ( + {factor.isSetup && ( )} - {!factor.setup && factorBeingSetup !== factor.id && ( + {!factor.isSetup && factorBeingSetup !== factor.id && (