diff --git a/package-lock.json b/package-lock.json index e291768..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 @@ -14955,7 +14963,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 +15800,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 +17477,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 +17667,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", @@ -17717,9 +17725,196 @@ "@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.0", + "version": "0.1.1", "devDependencies": { "@shared/eslint": "*", "@shared/nodejs": "*", @@ -17855,7 +18050,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 +18660,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 +19417,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 +19549,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" }, 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..c340d5f --- /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 --testTimeout=30000" + }, + "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" +} \ No newline at end of file diff --git a/packages/profile-security-nodejs/src/constants.ts b/packages/profile-security-nodejs/src/constants.ts new file mode 100644 index 0000000..a94ea87 --- /dev/null +++ b/packages/profile-security-nodejs/src/constants.ts @@ -0,0 +1,14 @@ +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}`; + +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..5c81173 --- /dev/null +++ b/packages/profile-security-nodejs/src/implementation.ts @@ -0,0 +1,557 @@ +import { getUser, deleteUser, getAvailableFirstFactors, User } from "supertokens-node"; +import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; +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, + // input needed for overriding + // eslint-disable-next-line @typescript-eslint/no-unused-vars + input: { 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" }; + } + + 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.", + }; + } + + const verifiedLoginMethod = user.loginMethods.find((lm) => lm.email === email && lm.verified); + if (!verifiedLoginMethod) { + return { + status: "ERROR", + message: "The user does not have this email address or is not verified", + }; + } + + const signUpResult = await signUp(session!.getTenantId(), email, password, 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.", + }; + } + // 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") { + 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" }; + }; + + 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" }; + }; + + unlinkAndRemoveRecipeUser = 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" }; + }; + + toggleSingleRequiredMfaFactorForUser = 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); + if (requiredFactorIds.includes(factorId)) { + await MultiFactorAuth.removeFromRequiredSecondaryFactorsForUser(userId, factorId, userContext); + } else { + 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]! }; + }; + + 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 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", + }; + }; + + renameTotpDeviceForUser = 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/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.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 new file mode 100644 index 0000000..cd4f3ce --- /dev/null +++ b/packages/profile-security-nodejs/src/plugin.ts @@ -0,0 +1,319 @@ +import { isRecipeInitialized } from "supertokens-node"; +import { SuperTokensPlugin } from "supertokens-node/types"; + +import { withRequestHandler } from "@shared/nodejs"; +import { createPluginInitFunction } from "@shared/js"; + +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, + Implementation, + SuperTokensPluginProfileSecurityNormalisedConfig +>( + (pluginConfig, implementation) => { + 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, userContext) => { + const userId = session!.getUserId(); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + return implementation.getConfigForClient({ + session: session!, + userContext, + }); + }), + }, + { + 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" }; + } + + return implementation.setUserPassword({ + userId, + email, + password: newPassword, + session: session!, + userContext, + }); + }), + }, + { + 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 { currentPassword, newPassword } = await req.getJSONBody(); + + return implementation.changeUserPassword({ + userId, + currentPassword, + newPassword, + session: session!, + userContext, + }); + }), + }, + { + path: HANDLE_BASE_PATH + "/user/unlink", + method: "post", + verifySessionOptions: { + 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 { recipeUserId } = await req.getJSONBody(); + + return implementation.unlinkAndRemoveRecipeUser({ + userId, + recipeUserId, + session: session!, + userContext, + }); + }), + }, + { + 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 { factorId } = await req.getJSONBody(); + + if (!factorId) { + return { status: "ERROR", message: "The factorId parameter is required" }; + } + + return implementation.toggleSingleRequiredMfaFactorForUser({ + userId, + factorId, + session: session!, + userContext, + }); + }), + }, + { + 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" }; + } + + return implementation.getRequiredSecondaryFactorsForUser({ + userId, + session: session!, + userContext, + }); + }), + }, + + { + 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 { email } = await req.getJSONBody(); + + return implementation.changeOtpEmailForUser({ + userId, + email, + session: session!, + userContext, + }); + }), + }, + { + 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 { phoneNumber } = await req.getJSONBody(); + + return implementation.sendSmsOtpForUserPhoneNumberChange({ + userId, + phoneNumber, + session: session!, + userContext, + }); + }), + }, + { + 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 { phoneNumber, code, deviceId, preAuthSessionId } = await req.getJSONBody(); + + return implementation.changeOtpPhoneNumberForUser({ + userId, + phoneNumber, + deviceId, + preAuthSessionId, + code, + session: session!, + userContext, + }); + }), + }, + { + 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 { name, newName } = await req.getJSONBody(); + + return implementation.renameTotpDeviceForUser({ + userId, + name, + newName, + session: session!, + userContext, + }); + }), + }, + { + 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" }; + } + + return implementation.getUser({ + userId, + session: session!, + userContext, + }); + }), + }, + ], + }; + }, + }; + }, + (config) => Implementation.init(config), + (pluginConfig) => { + return { + 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 new file mode 100644 index 0000000..86725db --- /dev/null +++ b/packages/profile-security-nodejs/src/types.ts @@ -0,0 +1,7 @@ +export type SuperTokensPluginProfileSecurityConfig = { + enableSettingPassword?: boolean; + enableThirdPartyLinkning?: boolean; + enableMfaConfiguration?: boolean; +}; + +export type SuperTokensPluginProfileSecurityNormalisedConfig = Required; 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/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/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/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..130f9a5 --- /dev/null +++ b/packages/profile-security-react/src/components/list-card/list-card.module.css @@ -0,0 +1,54 @@ +.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; + gap: 10px; +} +.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-wrap: wrap; + 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..e94201d --- /dev/null +++ b/packages/profile-security-react/src/components/list-card/list-card.tsx @@ -0,0 +1,55 @@ +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, + children, + FooterComponent, +}: { + title?: string; + children: any; + FooterComponent?: React.ReactElement; +}) => { + return ( + + {children.length && ( +
+ {children} +
+ )} + + {FooterComponent} +
+ ); +}; + +export function ListCardFooter({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export const ListCardItemActions = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +export const ListCardItem = ({ + children, + ActionsComponent, +}: { + children: any; + ActionsComponent?: React.ReactElement; +}) => { + return ( +
+ {children} + {ActionsComponent} +
+ ); +}; 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..79cfac5 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/change-password-section.tsx @@ -0,0 +1,137 @@ +import { Button, PasswordInput, useToast, usePrettyAction } from "@shared/ui"; +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"; + +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 [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); + if (res.status !== "OK") { + throw new Error(res.message); + } + + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setConfirmPasswordError(""); + setPasswordInputVisible(false); + }, + [currentPassword, newPassword, addToast], + { + errorMessage: t("PL_SEC_CHANGE_PASSWORD_ERROR_CHANGE"), + successMessage: t("PL_SEC_CHANGE_PASSWORD_SUCCESS_CHANGE"), + setLoading: setIsLoading, + }, + ); + + useEffect(() => { + if (!newPassword || !confirmPassword) { + setConfirmPasswordError(""); + return; + } + + if (newPassword !== confirmPassword) { + setConfirmPasswordError(t("PL_SEC_SET_PASSWORD_CONFIRM_PASSWORD_ERROR")); + } else { + setConfirmPasswordError(""); + } + }, [newPassword, confirmPassword, t]); + + return ( +
+ + {passwordInputVisible ? ( + <> + +
+ +
+ + + ) : ( + + )} +
+ + {passwordInputVisible && ( + + + + + )} +
+ ); +}; 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..0641ab8 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-email-otp.tsx @@ -0,0 +1,238 @@ +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 { logDebugMessage } from "../../logger"; +import { usePluginContext } from "../../plugin"; +import { FormActions } from "../form-actions"; +import { FormRow } from "../form-item"; + +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) { + logDebugMessage("User has no email OTP login method"); + return null; + } + if (loginMethods.length > 1) { + logDebugMessage("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); + }} + /> + )} + + +
+ + + + + + )} + + {emailSent && ( + <> + + { + 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..81bf870 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-phone-otp.tsx @@ -0,0 +1,299 @@ +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 { logDebugMessage } from "../../logger"; +import { usePluginContext } from "../../plugin"; +import { FormActions } from "../form-actions"; +import { FormRow } from "../form-item"; + +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) { + logDebugMessage("User has no phone OTP login method"); + return null; + } + if (loginMethods.length > 1) { + logDebugMessage("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; + } + if (!codeDetails) { + 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, codeDetails, code], + { + 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 ( +
+ {!codeSent && ( + <> + + { + 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); + }} + /> + + +
+ + + + + + )} + + {smsSent && ( + <> + + { + 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..12de59b --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/mfa-factor-totp.tsx @@ -0,0 +1,291 @@ +import { Button, usePrettyAction, TextInput } from "@shared/ui"; +import classNames from "classnames/bind"; +import { useState, useEffect, useCallback } 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 { ListCard, ListCardFooter, ListCardItem, ListCardItemActions } from "../list-card"; + +import style from "./security-section.module.css"; + +const cx = classNames.bind(style); + +export const MfaFactorTotpList = ({ user, onSuccess }: { user: User; onSuccess: () => Promise }) => { + const [totpDevices, setTotpDevices] = useState<{ name: string; verified: boolean }[]>([]); + 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 { 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, + })) + .filter((device) => device.verified), + ); + }, []); + + const addTotp = usePrettyAction( + async () => { + const res = await createDevice({ deviceName: addName || undefined }); + if (res.status !== "OK") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP")); + } + + setAddName(""); + setTotpVerificationCode(""); + + loadTotps(); + + setNewTotpDevice({ name: res.deviceName, qrString: res.qrCodeString }); + }, + [addName], + { errorMessage: t("PL_SEC_MFA_TOTP_ERROR_ADD_TOTP") }, + ); + + const verifyTotp = usePrettyAction( + async () => { + let res: { status: string }; + if (!newTotpDevice) { + res = await verifyCode({ totp: totpVerificationCode }); + } else { + res = await verifyDevice({ deviceName: addName || newTotpDevice.name, totp: totpVerificationCode }); + } + + if (res.status !== "OK") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_VERIFY_DEVICE")); + } + + setNewTotpDevice(undefined); + setTotpVerificationCode(""); + + loadTotps(); + }, + [newTotpDevice, totpVerificationCode, addName], + { + 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) => { + setActiveTotpDevice({ action: "rename", name, qrCodeString: "" }); + setRenameName(name); + }, []); + + const cancelRename = useCallback(() => { + setRenameName(""); + setActiveTotpDevice(undefined); + }, []); + + const updateTotpName = usePrettyAction( + async () => { + if (activeTotpDevice?.action !== "rename") { + throw new Error(t("PL_SEC_MFA_TOTP_ERROR_NO_TOTP_CONFIGURABLE")); + } + + const res = await api.updateMfaTotpName({ + name: activeTotpDevice.name, + newName: renameName, + }); + if (res.status !== "OK") { + throw new Error(res.message); + } + + setActiveTotpDevice(undefined); + setRenameName(""); + loadTotps(); + }, + [activeTotpDevice, renameName], + { + successMessage: t("PL_SEC_MFA_TOTP_SUCCESS_UPDATE_NAME"), + errorMessage: t("PL_SEC_MFA_TOTP_ERROR_UPDATE_NAME"), + }, + ); + + const isRenaming = (totpName: string) => activeTotpDevice?.action === "rename" && activeTotpDevice?.name === totpName; + + useEffect(() => { + loadTotps(); + }, []); + + 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) && ( + <> +
+ {t("PL_SEC_MFA_TOTP_RENAME_DEVICE_NAME_LABEL")} +
+ + setRenameName(value as string)} + /> + + )} +
+ ))} +
+
+ ); +}; + +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 new file mode 100644 index 0000000..340eae9 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/mfa-section.tsx @@ -0,0 +1,211 @@ +import { Button, ToggleInput, usePrettyAction } 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; + isSetup: boolean; + isRequired: boolean; + }[] + >([]); + + const { api, t } = usePluginContext(); + + 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 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 { + requiredSecondaryFactors: res.requiredSecondaryFactors, + ...mfaInfo, + }; + }, + [], + { + setLoading: setIsLoading, + }, + ); + + const toggleSecondaryFactor = usePrettyAction( + async (factorId: string) => { + const res = await api.setRequiredSecondaryFactor(factorId); + + if (res.status !== "OK") { + throw new Error(res.message); + } + }, + [], + { + 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]); + + 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.isRequired && factor.isSetup) { + type = "config"; + } else { + return null; + } + + return type === "config" ? ( + + ) : ( + + ); + }, + [user, _onSuccess, factorBeingSetup], + ); + + useEffect(() => { + if (isLoaded) { + return; + } + loadMfaInfo(); + setIsLoaded(true); + }, [isLoaded, loadMfaInfo]); + + if (!isLoaded) { + return null; + } + + return ( +
+ {secondaryFactors.map((factor) => ( +
+
+
+ + {t(factor.name as TranslationKeys)} + + + + {t(factor.description as TranslationKeys)} + +
+ +
+ {factor.isSetup && ( + toggleSecondaryFactor(factor.id)} + /> + )} + + {!factor.isSetup && factorBeingSetup !== factor.id && ( + + )} + + {!factor.isSetup && factorBeingSetup === factor.id && ( + + )} +
+
+ + {getManageFactorComponent(factor)} +
+ ))} +
+ ); +}; 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..dc3c1b9 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/security-section.module.css @@ -0,0 +1,214 @@ +.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; + width: 100%; +} + +.supertokens-plugin-profile-security-header { + position: relative; + 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; +} + +.supertokens-plugin-profile-security-header p { + margin: 0; + font-size: var(--wa-font-size-s); + line-height: 1.4; +} + +.supertokens-plugin-profile-security-group { + display: flex; + flex-direction: column; + 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; +} + +.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; +} + +.supertokens-plugin-profile-security-form { + display: flex; + flex-direction: column; + gap: 24px; +} + +.supertokens-plugin-profile-security-no-linked-accounts { + margin-bottom: 13px; + color: var(--wa-color-neutral-95); +} + +/* 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; +} + +.supertokens-plugin-profile-security-linked-account-tag { + font-size: 20px; +} + +.supertokens-plugin-profile-security-linked-account-name { + display: flex; + align-items: center; + 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; +} + +.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; +} + +.supertokens-plugin-profile-security-passkey-date { + margin-left: auto; +} + +.supertokens-plugin-profile-security-passkey-email-select { + flex: 1; +} + +.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; + margin-bottom: 10px; +} + +.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; +} + +.supertokens-plugin-profile-security-second-factor-method-header-content { + display: flex; + flex-direction: column; + gap: 4px; +} +.supertokens-plugin-profile-security-second-factor-method-label { + font-weight: 500; + font-size: 14px; + line-height: 1.4; + color: var(--title-color); +} + +.supertokens-plugin-profile-security-second-factor-method-description { + font-size: 14px; + line-height: 1.4; +} + +.supertokens-plugin-profile-security-second-factor-method-header-actions { + margin-left: auto; +} + +.supertokens-plugin-profile-security-second-factor-manage { + margin-top: 14px; +} + +/* TOTP */ +.supertokens-plugin-profile-security-totp-name-label { + font-size: 14px; + line-height: 1.4; + font-weight: 500; + word-break: keep-all; +} +.supertokens-plugin-profile-security-totp-name { + flex: 1; +} + +.supertokens-plugin-profile-security-totp-verify-description { + flex: 1 1 100%; + order: -1; + margin-bottom: 10px; +} + +.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; +} + +.supertokens-plugin-profile-security-totp-verify-code { + flex: 1; +} + +.supertokens-plugin-profile-security-method-action { + margin-left: auto; +} 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..0817b5b --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/security-section.tsx @@ -0,0 +1,150 @@ +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; + } + + Promise.all([loadConfig(), 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..91ea538 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/set-password-section.tsx @@ -0,0 +1,161 @@ +import { Button, SelectInput, PasswordInput, useToast, usePrettyAction } from "@shared/ui"; +import classNames from "classnames/bind"; +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"; + +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 [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({ + 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 (!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; + } + + 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} + /> + + + + {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 new file mode 100644 index 0000000..71785b4 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/set-webauthn-section.tsx @@ -0,0 +1,84 @@ +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 { FormActions } from "../form-actions"; +import { FormRow } from "../form-item"; + +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: {}, + shouldTryLinkingWithSessionUser: true, + }); + }, + [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..bbc27d0 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/third-party-section.tsx @@ -0,0 +1,203 @@ +import { Button, Callout, Tag, 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.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 new file mode 100644 index 0000000..06913d5 --- /dev/null +++ b/packages/profile-security-react/src/components/security-section/webauthn-section.tsx @@ -0,0 +1,193 @@ +import { Button, Card, 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 { ListCard, ListCardFooter, ListCardItem, ListCardItemActions } from "../list-card"; + +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); + } + + loadCredentialsAction(); + }, [user]); + + const webAuthnEmails = useMemo(() => { + return user?.loginMethods.filter((lm: any) => lm.recipeId === "webauthn").map((lm: any) => lm.email) ?? []; + }, [user]); + + const loadCredentialsAction = 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 onActionSuccess = useCallback(async () => { + await loadCredentialsAction(); + await onSuccess(); + }, [onSuccess, loadCredentialsAction]); + + const removeCredentialAction = 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: onActionSuccess, + errorMessage: t("PL_SEC_WEBAUTHN_ERROR_REMOVE_CREDENTIAL"), + setLoading: setIsLoading, + }, + ); + + const addCredentialAction = 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 loadCredentialsAction(); + }, + [webauthnEmail, user], + { + errorMessage: t("PL_SEC_WEBAUTHN_ERROR_ADD_CREDENTIAL"), + successMessage: t("PL_SEC_WEBAUTHN_SUCCESS_ADD_CREDENTIAL"), + setLoading: setIsLoading, + onSuccess: onActionSuccess, + }, + ); + + if (!user) { + return null; + } + + 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()} + + + ))} +
+
+ ); +}; 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..3974080 --- /dev/null +++ b/packages/profile-security-react/src/translations.ts @@ -0,0 +1,130 @@ +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_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_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_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_PLACEHOLDER: "Enter your new 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_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: "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_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_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_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_LIST_TITLE: "Device List", + PL_SEC_MFA_TOTP_REMOVE_BUTTON: "Remove", + PL_SEC_MFA_TOTP_RENAME_BUTTON: "Rename", + 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_SUCCESS_REMOVE_TOTP: "TOTP device removed 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_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_ADD_DEVICE: "Add device", + 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: "Scan the QR code with authenticator app", + PL_SEC_MFA_TOTP_SETUP_VERIFY_BUTTON: "Verify", + 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: "Passkey", + 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: "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\u00A0Email", // needs non breakable space + PL_SEC_WEBAUTHN_REMOVE_BUTTON: "Remove", + PL_SEC_WEBAUTHN_ADD_CREDENTIAL_BUTTON: "Add Passkey", + }, +} 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, + }, + }, + }; +}); 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; } 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; }