From c2fa5d8db0c637194dd3124b3dcd99f0204737bc Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 28 Aug 2025 10:15:30 +0300 Subject: [PATCH 01/46] component type fixes --- .../form-input/field-components.tsx | 52 ++++--------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/shared/ui/src/components/form-input/field-components.tsx b/shared/ui/src/components/form-input/field-components.tsx index dc565ea..1d7cb29 100644 --- a/shared/ui/src/components/form-input/field-components.tsx +++ b/shared/ui/src/components/form-input/field-components.tsx @@ -26,7 +26,7 @@ export const StringFieldComponent = ({ value, onChange, error, className, ...fie ); // Text field component (textarea) -export const TextFieldComponent: React.FC> = ({ value, onChange, error, className, ...field }) => ( +export const TextFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( > = ({ value, onChang ); // Number field component -export const NumberFieldComponent: React.FC> = ({ - value, - onChange, - error, - className, - ...field -}) => ( +export const NumberFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( > = ({ ); // Boolean field component -export const BooleanFieldComponent: React.FC> = ({ - value, - onChange, - error, - className, - ...field -}) => ( +export const BooleanFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( > = ({ ); // Email field component -export const EmailFieldComponent: React.FC> = ({ value, onChange, error, className, ...field }) => ( +export const EmailFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( > = ({ value, onChan ); // Phone field component -export const PhoneFieldComponent: React.FC> = ({ value, onChange, error, className, ...field }) => ( +export const PhoneFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( > = ({ value, onChan ); // Date field component -export const DateFieldComponent: React.FC> = ({ value, onChange, error, className, ...field }) => ( +export const DateFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( > = ({ value, onChang ); // Select field component -export const SelectFieldComponent: React.FC> = ({ value, onChange, error, className, ...field }) => ( +export const SelectFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( > = ({ value, onCha ); // Multiselect field component -export const MultiselectFieldComponent: React.FC> = ({ - value, - onChange, - error, - className, - ...field -}) => ( +export const MultiselectFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( > = ({ /> ); -export const PasswordFieldComponent: React.FC> = ({ - value, - onChange, - error, - className, - ...field -}) => { +export const PasswordFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => { return ( > = ({ }; // URL field component -export const UrlFieldComponent: React.FC> = ({ value, onChange, error, className, ...field }) => ( +export const UrlFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( > = ({ value, onChange ); // Image URL field component -export const ImageUrlFieldComponent: React.FC> = ({ - value, - onChange, - error, - className, - ...field -}) => ( +export const ImageUrlFieldComponent = ({ value, onChange, error, className, ...field }: BaseInput) => ( Date: Thu, 28 Aug 2025 10:19:28 +0300 Subject: [PATCH 02/46] ported progressive profiling plugin --- package-lock.json | 212 ++++++++++ package.json | 4 +- .../progressive-profiling-nodejs/.eslintrc.js | 10 + .../.prettierrc.js | 4 + .../progressive-profiling-nodejs/CHANGELOG.md | 1 + .../progressive-profiling-nodejs/README.md | 1 + .../progressive-profiling-nodejs/package.json | 58 +++ .../src/constants.ts | 7 + .../progressive-profiling-nodejs/src/index.ts | 7 + .../src/logger.ts | 4 + .../src/plugin.ts | 377 ++++++++++++++++++ .../progressive-profiling-nodejs/src/types.ts | 48 +++ .../tsconfig.json | 13 + .../vitest.config.ts | 13 + .../progressive-profiling-react/.eslintrc.js | 14 + .../.prettierrc.js | 4 + .../progressive-profiling-react/CHANGELOG.md | 1 + .../progressive-profiling-react/README.md | 1 + .../progressive-profiling-react/package.json | 61 +++ .../progressive-profiling-react/src/api.ts | 32 ++ .../src/components/index.ts | 2 + .../src/components/page-wrapper/index.ts | 1 + .../page-wrapper/page-wrapper.module.css | 4 + .../components/page-wrapper/page-wrapper.tsx | 14 + .../src/components/profiling-card/index.ts | 1 + .../profiling-card/profiling-card.module.css | 43 ++ .../profiling-card/profiling-card.tsx | 225 +++++++++++ .../src/constants.ts | 38 ++ .../progressive-profiling-react/src/css.d.ts | 29 ++ .../progressive-profiling-react/src/index.ts | 12 + .../progressive-profiling-react/src/logger.ts | 5 + .../progressive-profiling-react/src/plugin.ts | 93 +++++ .../src/setup-profile-page.tsx | 21 + .../src/translations.ts | 16 + .../progressive-profiling-react/src/types.ts | 12 + .../src/user-profile-wrapper.tsx | 87 ++++ .../progressive-profiling-react/tsconfig.json | 13 + .../vite.config.ts | 40 ++ .../.prettierrc.js | 4 + .../progressive-profiling-shared/README.md | 1 + .../progressive-profiling-shared/package.json | 14 + .../progressive-profiling-shared/src/index.ts | 1 + .../progressive-profiling-shared/src/types.ts | 34 ++ .../tsconfig.json | 12 + 44 files changed, 1593 insertions(+), 1 deletion(-) create mode 100644 packages/progressive-profiling-nodejs/.eslintrc.js create mode 100644 packages/progressive-profiling-nodejs/.prettierrc.js create mode 100644 packages/progressive-profiling-nodejs/CHANGELOG.md create mode 100644 packages/progressive-profiling-nodejs/README.md create mode 100644 packages/progressive-profiling-nodejs/package.json create mode 100644 packages/progressive-profiling-nodejs/src/constants.ts create mode 100644 packages/progressive-profiling-nodejs/src/index.ts create mode 100644 packages/progressive-profiling-nodejs/src/logger.ts create mode 100644 packages/progressive-profiling-nodejs/src/plugin.ts create mode 100644 packages/progressive-profiling-nodejs/src/types.ts create mode 100644 packages/progressive-profiling-nodejs/tsconfig.json create mode 100644 packages/progressive-profiling-nodejs/vitest.config.ts create mode 100644 packages/progressive-profiling-react/.eslintrc.js create mode 100644 packages/progressive-profiling-react/.prettierrc.js create mode 100644 packages/progressive-profiling-react/CHANGELOG.md create mode 100644 packages/progressive-profiling-react/README.md create mode 100644 packages/progressive-profiling-react/package.json create mode 100644 packages/progressive-profiling-react/src/api.ts create mode 100644 packages/progressive-profiling-react/src/components/index.ts create mode 100644 packages/progressive-profiling-react/src/components/page-wrapper/index.ts create mode 100644 packages/progressive-profiling-react/src/components/page-wrapper/page-wrapper.module.css create mode 100644 packages/progressive-profiling-react/src/components/page-wrapper/page-wrapper.tsx create mode 100644 packages/progressive-profiling-react/src/components/profiling-card/index.ts create mode 100644 packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css create mode 100644 packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx create mode 100644 packages/progressive-profiling-react/src/constants.ts create mode 100644 packages/progressive-profiling-react/src/css.d.ts create mode 100644 packages/progressive-profiling-react/src/index.ts create mode 100644 packages/progressive-profiling-react/src/logger.ts create mode 100644 packages/progressive-profiling-react/src/plugin.ts create mode 100644 packages/progressive-profiling-react/src/setup-profile-page.tsx create mode 100644 packages/progressive-profiling-react/src/translations.ts create mode 100644 packages/progressive-profiling-react/src/types.ts create mode 100644 packages/progressive-profiling-react/src/user-profile-wrapper.tsx create mode 100644 packages/progressive-profiling-react/tsconfig.json create mode 100644 packages/progressive-profiling-react/vite.config.ts create mode 100644 packages/progressive-profiling-shared/.prettierrc.js create mode 100644 packages/progressive-profiling-shared/README.md create mode 100644 packages/progressive-profiling-shared/package.json create mode 100644 packages/progressive-profiling-shared/src/index.ts create mode 100644 packages/progressive-profiling-shared/src/types.ts create mode 100644 packages/progressive-profiling-shared/tsconfig.json diff --git a/package-lock.json b/package-lock.json index f78c540..59ad7da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@shared/js": "*", "@shared/nodejs": "*", "@shared/react": "*", + "@supertokens-plugins/progressive-profiling-shared": "*", "tsup": "^8.5.0" }, "devDependencies": { @@ -26,6 +27,7 @@ "rollup-plugin-peer-deps-external": "^2.2.4", "turbo": "^2.5.5", "vite": "^6.3.5", + "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-dts": "^4.5.4" }, "engines": { @@ -2628,6 +2630,18 @@ "resolved": "packages/captcha-react", "link": true }, + "node_modules/@supertokens-plugins/progressive-profiling-nodejs": { + "resolved": "packages/progressive-profiling-nodejs", + "link": true + }, + "node_modules/@supertokens-plugins/progressive-profiling-react": { + "resolved": "packages/progressive-profiling-react", + "link": true + }, + "node_modules/@supertokens-plugins/progressive-profiling-shared": { + "resolved": "packages/progressive-profiling-shared", + "link": true + }, "node_modules/@supertokens-plugins/user-banning-nodejs": { "resolved": "packages/user-banning-nodejs", "link": true @@ -13168,6 +13182,16 @@ } } }, + "node_modules/vite-plugin-css-injected-by-js": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/vite-plugin-css-injected-by-js/-/vite-plugin-css-injected-by-js-3.5.2.tgz", + "integrity": "sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": ">2.0.0-0" + } + }, "node_modules/vite-plugin-dts": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz", @@ -15204,6 +15228,194 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/progressive-profiling-nodejs": { + "name": "@supertokens-plugins/progressive-profiling-nodejs", + "version": "0.0.2-beta.2", + "dependencies": { + "@supertokens-plugins/progressive-profiling-shared": "*" + }, + "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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-react": { + "name": "@supertokens-plugins/progressive-profiling-react", + "version": "0.0.2-beta.2", + "dependencies": { + "@shared/js": "*", + "@shared/react": "*", + "@shared/ui": "*", + "@supertokens-plugins/progressive-profiling-shared": "*", + "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" + }, + "peerDependencies": { + "react": ">=18.3.1", + "react-dom": ">=18.3.1", + "supertokens-auth-react": ">=0.50.0" + } + }, + "packages/progressive-profiling-react/node_modules/@types/react": { + "version": "17.0.88", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.88.tgz", + "integrity": "sha512-HEOvpzcFWkEcHq4EsTChnpimRc3Lz1/qzYRDFtobFp4obVa6QVjCDMjWmkgxgaTYttNvyjnldY8MUflGp5YiUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.0.2" + } + }, + "packages/progressive-profiling-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-shared": { + "name": "@supertokens-plugins/progressive-profiling-shared", + "version": "0.0.1", + "devDependencies": { + "@shared/eslint": "*", + "@shared/tsconfig": "*" + } + }, "packages/user-banning-nodejs": { "name": "@supertokens-plugins/user-banning-nodejs", "version": "0.0.2-beta.2", diff --git a/package.json b/package.json index 166309e..dd6f861 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "turbo": "^2.5.5", "@vitejs/plugin-react": "^4.5.2", "rollup-plugin-peer-deps-external": "^2.2.4", + "vite-plugin-css-injected-by-js": "^3.5.2", "vite": "^6.3.5", "vite-plugin-dts": "^4.5.4" }, @@ -38,6 +39,7 @@ "@shared/js": "*", "@shared/nodejs": "*", "@shared/react": "*", + "@supertokens-plugins/progressive-profiling-shared": "*", "tsup": "^8.5.0" } -} \ No newline at end of file +} diff --git a/packages/progressive-profiling-nodejs/.eslintrc.js b/packages/progressive-profiling-nodejs/.eslintrc.js new file mode 100644 index 0000000..915d347 --- /dev/null +++ b/packages/progressive-profiling-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/progressive-profiling-nodejs/.prettierrc.js b/packages/progressive-profiling-nodejs/.prettierrc.js new file mode 100644 index 0000000..8986fc5 --- /dev/null +++ b/packages/progressive-profiling-nodejs/.prettierrc.js @@ -0,0 +1,4 @@ +/** @type {import("prettier").Config} */ +module.exports = { + ...require("@shared/eslint/prettier"), +}; diff --git a/packages/progressive-profiling-nodejs/CHANGELOG.md b/packages/progressive-profiling-nodejs/CHANGELOG.md new file mode 100644 index 0000000..8c62368 --- /dev/null +++ b/packages/progressive-profiling-nodejs/CHANGELOG.md @@ -0,0 +1 @@ +# @supertokens-plugins/progressive-profiling-nodejs diff --git a/packages/progressive-profiling-nodejs/README.md b/packages/progressive-profiling-nodejs/README.md new file mode 100644 index 0000000..bebc6fc --- /dev/null +++ b/packages/progressive-profiling-nodejs/README.md @@ -0,0 +1 @@ +# SuperTokens Plugin Progressive Profiling diff --git a/packages/progressive-profiling-nodejs/package.json b/packages/progressive-profiling-nodejs/package.json new file mode 100644 index 0000000..0d6b49c --- /dev/null +++ b/packages/progressive-profiling-nodejs/package.json @@ -0,0 +1,58 @@ +{ + "name": "@supertokens-plugins/progressive-profiling-nodejs", + "version": "0.0.2-beta.2", + "description": "Progressive Profiling Plugin for SuperTokens", + "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/progressive-profiling-nodejs/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/supertokens/supertokens-plugins.git", + "directory": "packages/progressive-profiling-nodejs" + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "pretty": "npx pretty-quick .", + "pretty-check": "npx pretty-quick --check .", + "test": "TEST_MODE=testing vitest run --pool=forks" + }, + "keywords": [ + "progressive-profiling", + "plugin", + "supertokens" + ], + "dependencies": { + "@supertokens-plugins/progressive-profiling-shared": "*" + }, + "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", + "exports": { + ".": { + "types": "dist/index.d.ts", + "default": "dist/index.js" + }, + "./index": { + "types": "dist/index.d.ts", + "default": "dist/index.js" + }, + "./index.js": { + "types": "dist/index.d.ts", + "default": "dist/index.js" + } + } +} diff --git a/packages/progressive-profiling-nodejs/src/constants.ts b/packages/progressive-profiling-nodejs/src/constants.ts new file mode 100644 index 0000000..7fe391a --- /dev/null +++ b/packages/progressive-profiling-nodejs/src/constants.ts @@ -0,0 +1,7 @@ +export const PLUGIN_ID = "supertokens-plugin-progressive-profiling"; +export const PLUGIN_VERSION = "0.0.1"; +export const PLUGIN_SDK_VERSION = ["23.0.1", ">=23.0.1"]; + +export const METADATA_KEY = `${PLUGIN_ID}`; + +export const HANDLE_BASE_PATH = `/plugin/${PLUGIN_ID}`; diff --git a/packages/progressive-profiling-nodejs/src/index.ts b/packages/progressive-profiling-nodejs/src/index.ts new file mode 100644 index 0000000..2bff055 --- /dev/null +++ b/packages/progressive-profiling-nodejs/src/index.ts @@ -0,0 +1,7 @@ +import { init } from "./plugin"; +import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; + +export type { RegisterSection } from "./types"; + +export { init, PLUGIN_ID, PLUGIN_VERSION }; +export default { init, PLUGIN_ID, PLUGIN_VERSION }; diff --git a/packages/progressive-profiling-nodejs/src/logger.ts b/packages/progressive-profiling-nodejs/src/logger.ts new file mode 100644 index 0000000..5943019 --- /dev/null +++ b/packages/progressive-profiling-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/progressive-profiling-nodejs/src/plugin.ts b/packages/progressive-profiling-nodejs/src/plugin.ts new file mode 100644 index 0000000..5370cac --- /dev/null +++ b/packages/progressive-profiling-nodejs/src/plugin.ts @@ -0,0 +1,377 @@ +import { SuperTokensPlugin } from "supertokens-node/types"; +import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; +import { BooleanClaim } from "supertokens-node/recipe/session/claims"; + +import { pluginUserMetadata, withRequestHandler } from "@shared/nodejs"; +import { createPluginInitFunction } from "@shared/js"; +import { ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; + +import { + FormSection, + SuperTokensPluginProfileProgressiveProfilingConfig, + RegisterSection, + UserMetadataConfig, +} from "./types"; +import { HANDLE_BASE_PATH, PLUGIN_ID, METADATA_KEY, PLUGIN_SDK_VERSION } from "./constants"; +import { enableDebugLogs, logDebugMessage } from "./logger"; + +const isSectionCompleted = (section: FormSection, data: ProfileFormData) => { + return section.fields.reduce((acc, field) => { + const value = data.find((d) => d.fieldId === field.id)?.value; + if (field.required && value === undefined) { + return acc && false; + } + + return acc && true; + }, true); +}; + +const areAllSectionsCompleted = (sections: FormSection[], profileConfig?: UserMetadataConfig) => { + return sections.reduce((acc, section) => { + return acc && (profileConfig?.sectionCompleted?.[section.id] ?? false); + }, true); +}; + +export const init = createPluginInitFunction( + () => { + const metadata = pluginUserMetadata<{ profileConfig?: UserMetadataConfig }>(METADATA_KEY); + + const existingSections: (FormSection & { registererId: string })[] = []; + + const existingRegistererHandlers: Record[0], "set" | "get">> = {}; + + const registerSection: RegisterSection = ({ registererId, sections, set, get }) => { + const registrableSections = sections + .filter((section) => { + const existingSection = existingSections.find((s) => s.id === section.id); + if (existingSection) { + logDebugMessage( + `Profile plugin section with id "${section.id}" already registered by "${existingSection.registererId}". Skipping...`, + ); + return false; + } + + return true; + }) + .map((section) => ({ + ...section, + registererId, + })); + + existingSections.push(...registrableSections); + existingRegistererHandlers[registererId] = { set, get }; + }; + + const getSections = () => { + return existingSections; + }; + + const setSectionValues = async (session: SessionContainerInterface, data: ProfileFormData) => { + const userId = session.getUserId(); + if (!userId) { + throw new Error("User not found"); + } + + const sections = getSections(); + + const sectionIdToRegistererIdMap = sections.reduce( + (acc, section) => { + return { ...acc, [section.id]: section.registererId }; + }, + {} as Record, + ); + + const sectionsById = sections.reduce( + (acc, section) => { + return { ...acc, [section.id]: section }; + }, + {} as Record, + ); + + const dataBySectionId = data.reduce( + (acc, row) => { + return { ...acc, [row.sectionId]: [...(acc[row.sectionId] ?? []), row] }; + }, + {} as Record, + ); + + const dataByRegistererId = data.reduce( + (acc, row) => { + const registererId = sectionIdToRegistererIdMap[row.sectionId]; + if (registererId) { + return { ...acc, [registererId]: [...(acc[registererId] ?? []), row] }; + } + return acc; + }, + {} as Record, + ); + + const validationErrors: { id: string; error: string }[] = []; + for (const row of data) { + const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); + if (!field) { + validationErrors.push({ + id: row.fieldId, + error: `Field with id "${row.fieldId}" not found`, + }); + continue; + } + + if (field.required && row.value === undefined) { + validationErrors.push({ + id: field.id, + error: `Field value for field "${field.id}" is required`, + }); + continue; + } + + const validationError = await field.validation?.(row.value); + if (validationError) { + validationErrors.push({ id: field.id, error: validationError }); + } + } + + const updatedData: ProfileFormData = []; + for (const registererId of Object.keys(dataByRegistererId)) { + const sectionHandlers = existingRegistererHandlers[registererId]; + if (!sectionHandlers) { + continue; + } + const sectionData = dataByRegistererId[registererId]; + if (!sectionData) { + continue; + } + + await sectionHandlers.set(sectionData, session); + // get all the data from the storage, since data could be updated from other places or updated partially + const data = await sectionHandlers.get(session); + updatedData.push(...data); + } + + // do it like this to have a unique list of sections to update + const sectionsToUpdate = Object.keys(dataBySectionId) + .map((sectionId) => sections.find((s) => s.id === sectionId)) + .filter((s) => s !== undefined); + const sectionsCompleted: Record = {}; + for (const section of sectionsToUpdate) { + sectionsCompleted[section.id] = isSectionCompleted( + section, + updatedData.filter((d) => d.sectionId === section.id), + ); + } + + const userMetadata = await metadata.get(userId); + const newUserMetadata = { + ...userMetadata, + profileConfig: { + ...userMetadata?.profileConfig, + sectionCompleted: { + ...(userMetadata?.profileConfig?.sectionCompleted ?? {}), + ...sectionsCompleted, + }, + }, + }; + await metadata.set(userId, newUserMetadata); + + // refresh the claim to make sure the frontend has the latest value + // but only if all sections are completed + const allSectionsCompleted = areAllSectionsCompleted(getSections(), newUserMetadata?.profileConfig); + if (allSectionsCompleted) { + await session.fetchAndSetClaim(ProgressiveProfilingCompletedClaim); + } + + return { status: "OK" }; + }; + + const getSectionValues = async (session: SessionContainerInterface) => { + const userId = session.getUserId(); + if (!userId) { + throw new Error("User not found"); + } + + const sections = getSections(); + + const sectionsByRegistererId = sections.reduce( + (acc, section) => { + return { ...acc, [section.registererId]: section }; + }, + {} as Record, + ); + + const data: ProfileFormData = []; + for (const registererId of Object.keys(sectionsByRegistererId)) { + const sectionHandlers = existingRegistererHandlers[registererId]; + if (!sectionHandlers) { + continue; + } + + const sectionData = await sectionHandlers.get(session); + data.push(...sectionData); + } + + return data; + }; + + const ProgressiveProfilingCompletedClaim = new BooleanClaim({ + key: `${PLUGIN_ID}-completed`, + fetchValue: async (userId) => { + const userMetadata = await metadata.get(userId); + return areAllSectionsCompleted(getSections(), userMetadata?.profileConfig); + }, + }); + + return { + id: PLUGIN_ID, + compatibleSDKVersions: PLUGIN_SDK_VERSION, + init: (config) => { + if (config.debug) { + enableDebugLogs(); + } + }, + routeHandlers() { + return { + status: "OK", + routeHandlers: [ + { + path: HANDLE_BASE_PATH + "/sections", + method: "get", + verifySessionOptions: { + sessionRequired: true, + overrideGlobalClaimValidators: (globalValidators) => { + // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile + return globalValidators.filter( + (validator) => validator.id !== ProgressiveProfilingCompletedClaim.key, + ); + }, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + if (!session) { + throw new Error("Session not found"); + } + + const userId = session.getUserId(userContext); + if (!userId) { + throw new Error("User not found"); + } + + const userMetadata = await metadata.get(userId); + + // map the sections to a json serializable value + const sections = getSections().map((section) => ({ + id: section.id, + label: section.label, + description: section.description, + completed: userMetadata?.profileConfig?.sectionCompleted?.[section.id] ?? false, + fields: section.fields.map((field) => { + return { + id: field.id, + label: field.label, + type: field.type, + required: field.required, + defaultValue: field.defaultValue, + placeholder: field.placeholder, + description: field.description, + options: field.options, + }; + }), + })); + + return { status: "OK", sections }; + }), + }, + { + path: HANDLE_BASE_PATH + "/profile", + method: "post", + verifySessionOptions: { + sessionRequired: true, + overrideGlobalClaimValidators: (globalValidators) => { + // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile + return globalValidators.filter( + (validator) => validator.id !== ProgressiveProfilingCompletedClaim.key, + ); + }, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + if (!session) { + return { status: "ERROR", message: "Session not found" }; + } + + const userId = session.getUserId(userContext); + if (!userId) { + return { status: "ERROR", message: "User not found" }; + } + + const payload: { data: ProfileFormData } = await req.getJSONBody(); + + return setSectionValues(session, payload.data); + }), + }, + { + path: HANDLE_BASE_PATH + "/profile", + method: "get", + verifySessionOptions: { + sessionRequired: true, + overrideGlobalClaimValidators: (globalValidators) => { + // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile + return globalValidators.filter( + (validator) => validator.id !== ProgressiveProfilingCompletedClaim.key, + ); + }, + }, + handler: withRequestHandler(async (req, res, session, userContext) => { + if (!session) { + throw new Error("Session not found"); + } + + const userId = session.getUserId(userContext); + if (!userId) { + throw new Error("User not found"); + } + + const fieldValues = await getSectionValues(session); + + return { status: "OK", data: fieldValues }; + }), + }, + ], + }; + }, + overrideMap: { + session: { + functions: (originalImplementation) => { + return { + ...originalImplementation, + getGlobalClaimValidators: async function (input) { + return [ + ...(await originalImplementation.getGlobalClaimValidators(input)), + ProgressiveProfilingCompletedClaim.validators.isTrue(), + ]; + }, + createNewSession: async (input) => { + input.accessTokenPayload = { + ...input.accessTokenPayload, + ...(await ProgressiveProfilingCompletedClaim.build( + input.userId, + input.recipeUserId, + input.tenantId, + input.accessTokenPayload, + input.userContext, + )), + }; + + return originalImplementation.createNewSession(input); + }, + }; + }, + }, + }, + exports: { + metadata, + registerSection, + getSections, + setSectionValues, + getSectionValues, + }, + }; + }, +); diff --git a/packages/progressive-profiling-nodejs/src/types.ts b/packages/progressive-profiling-nodejs/src/types.ts new file mode 100644 index 0000000..e0e596a --- /dev/null +++ b/packages/progressive-profiling-nodejs/src/types.ts @@ -0,0 +1,48 @@ +import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; +import { ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; + +export type FormFieldValue = string | number | boolean | null | undefined | string[]; + +export type FormField = { + id: string; + label: string; + type: + | "string" + | "text" + | "number" + | "boolean" + | "email" + | "phone" + | "date" + | "select" + | "multiselect" + | "password" + | "url" + | "image-url" + | "toggle"; + required: boolean; + defaultValue?: FormFieldValue; + placeholder?: string; + description?: string; + validation?: (value: FormFieldValue) => Promise; + options?: { value: FormFieldValue; label: string }[]; +}; + +export type FormSection = { + id: string; + label: string; + description?: string; + fields: FormField[]; +}; +export type SuperTokensPluginProfileProgressiveProfilingConfig = undefined; + +export type UserMetadataConfig = { + sectionCompleted: Record; +}; + +export type RegisterSection = (section: { + registererId: string; + sections: FormSection[]; + set: (data: ProfileFormData, session: SessionContainerInterface | undefined) => Promise; + get: (session: SessionContainerInterface | undefined) => Promise; +}) => void; diff --git a/packages/progressive-profiling-nodejs/tsconfig.json b/packages/progressive-profiling-nodejs/tsconfig.json new file mode 100644 index 0000000..06f8f7b --- /dev/null +++ b/packages/progressive-profiling-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/progressive-profiling-nodejs/vitest.config.ts b/packages/progressive-profiling-nodejs/vitest.config.ts new file mode 100644 index 0000000..826c9b2 --- /dev/null +++ b/packages/progressive-profiling-nodejs/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + poolOptions: { + forks: { + singleFork: true, + }, + }, + }, +}); diff --git a/packages/progressive-profiling-react/.eslintrc.js b/packages/progressive-profiling-react/.eslintrc.js new file mode 100644 index 0000000..3c453d2 --- /dev/null +++ b/packages/progressive-profiling-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/progressive-profiling-react/.prettierrc.js b/packages/progressive-profiling-react/.prettierrc.js new file mode 100644 index 0000000..8986fc5 --- /dev/null +++ b/packages/progressive-profiling-react/.prettierrc.js @@ -0,0 +1,4 @@ +/** @type {import("prettier").Config} */ +module.exports = { + ...require("@shared/eslint/prettier"), +}; diff --git a/packages/progressive-profiling-react/CHANGELOG.md b/packages/progressive-profiling-react/CHANGELOG.md new file mode 100644 index 0000000..9fe21ab --- /dev/null +++ b/packages/progressive-profiling-react/CHANGELOG.md @@ -0,0 +1 @@ +# @supertokens-plugins/progressive-profiling-react diff --git a/packages/progressive-profiling-react/README.md b/packages/progressive-profiling-react/README.md new file mode 100644 index 0000000..bebc6fc --- /dev/null +++ b/packages/progressive-profiling-react/README.md @@ -0,0 +1 @@ +# SuperTokens Plugin Progressive Profiling diff --git a/packages/progressive-profiling-react/package.json b/packages/progressive-profiling-react/package.json new file mode 100644 index 0000000..dedb9f1 --- /dev/null +++ b/packages/progressive-profiling-react/package.json @@ -0,0 +1,61 @@ +{ + "name": "@supertokens-plugins/progressive-profiling-react", + "version": "0.0.2-beta.2", + "description": "Progressive Profiling Plugin for SuperTokens", + "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/progressive-profiling-react/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/supertokens/supertokens-plugins.git", + "directory": "packages/progressive-profiling-react" + }, + "scripts": { + "build": "vite build && npm run pretty", + "pretty": "npx pretty-quick .", + "pretty-check": "npx pretty-quick --check ." + }, + "keywords": [ + "progressive-profiling", + "plugin", + "supertokens" + ], + "dependencies": { + "@shared/js": "*", + "@shared/react": "*", + "@shared/ui": "*", + "@supertokens-plugins/progressive-profiling-shared": "*", + "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" + }, + "browser": { + "fs": false + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "dist/index.d.ts", + "default": "dist/index.js" + }, + "./index": { + "types": "dist/index.d.ts", + "default": "dist/index.js" + }, + "./index.js": { + "types": "dist/index.d.ts", + "default": "dist/index.js" + } + } +} diff --git a/packages/progressive-profiling-react/src/api.ts b/packages/progressive-profiling-react/src/api.ts new file mode 100644 index 0000000..ec7a106 --- /dev/null +++ b/packages/progressive-profiling-react/src/api.ts @@ -0,0 +1,32 @@ +import { getQuerier } from "@shared/react"; +import { FormSection, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; + +export const getApi = (querier: ReturnType) => { + const getSections = async () => { + return await querier.get<{ status: "OK"; sections: FormSection[] } | { status: "ERROR"; message: string }>( + "/sections", + { + withSession: true, + }, + ); + }; + + const getProfile = async () => { + return await querier.get<{ status: "OK"; data: ProfileFormData } | { status: "ERROR"; message: string }>( + "/profile", + { withSession: true }, + ); + }; + + const updateProfile = async (payload: { data: ProfileFormData }) => { + return await querier.post<{ status: "OK" } | { status: "ERROR"; message: string }>("/profile", payload, { + withSession: true, + }); + }; + + return { + getProfile, + updateProfile, + getSections, + }; +}; diff --git a/packages/progressive-profiling-react/src/components/index.ts b/packages/progressive-profiling-react/src/components/index.ts new file mode 100644 index 0000000..415cfd0 --- /dev/null +++ b/packages/progressive-profiling-react/src/components/index.ts @@ -0,0 +1,2 @@ +export * from "./profiling-card"; +export * from "./page-wrapper"; diff --git a/packages/progressive-profiling-react/src/components/page-wrapper/index.ts b/packages/progressive-profiling-react/src/components/page-wrapper/index.ts new file mode 100644 index 0000000..a66493a --- /dev/null +++ b/packages/progressive-profiling-react/src/components/page-wrapper/index.ts @@ -0,0 +1 @@ +export * from "./page-wrapper"; diff --git a/packages/progressive-profiling-react/src/components/page-wrapper/page-wrapper.module.css b/packages/progressive-profiling-react/src/components/page-wrapper/page-wrapper.module.css new file mode 100644 index 0000000..06ab172 --- /dev/null +++ b/packages/progressive-profiling-react/src/components/page-wrapper/page-wrapper.module.css @@ -0,0 +1,4 @@ +.page-wrapper { + width: 100%; + height: auto; +} diff --git a/packages/progressive-profiling-react/src/components/page-wrapper/page-wrapper.tsx b/packages/progressive-profiling-react/src/components/page-wrapper/page-wrapper.tsx new file mode 100644 index 0000000..3d14ebe --- /dev/null +++ b/packages/progressive-profiling-react/src/components/page-wrapper/page-wrapper.tsx @@ -0,0 +1,14 @@ +import classNames from "classnames/bind"; +import { ReactNode } from "react"; + +import styles from "./page-wrapper.module.css"; + +const cx = classNames.bind(styles); + +export const PageWrapper = ({ children, style }: { children: ReactNode; style?: React.CSSProperties }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/packages/progressive-profiling-react/src/components/profiling-card/index.ts b/packages/progressive-profiling-react/src/components/profiling-card/index.ts new file mode 100644 index 0000000..d400f19 --- /dev/null +++ b/packages/progressive-profiling-react/src/components/profiling-card/index.ts @@ -0,0 +1 @@ +export * from "./profiling-card"; diff --git a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css b/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css new file mode 100644 index 0000000..dd0e5d6 --- /dev/null +++ b/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css @@ -0,0 +1,43 @@ +.profiling-card-bullets { + display: flex; + align-items: center; + justify-content: space-evenly; + flex-direction: row; + width: 100%; + margin-bottom: var(--plugin-spacing-2xl); +} + +.profiling-card-bullets .profiling-card-bullet { + width: 36px; + height: 36px; + border-radius: 50%; + cursor: pointer; + text-align: center; + line-height: 36px; + font-size: 14px; + transition: background-color 0.2s; + background-color: white; + border: 1px solid #07c; + color: #07c; +} + +.profiling-card-bullets .profiling-card-bullet.active { + font-weight: 700; + background-color: #07c; + color: #fff; +} +.profiling-card-bullets .profiling-card-bullet.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.profiling-card-bullets .profiling-card-bullet:hover { + background-color: #005fa3; + color: #fff; +} + +.profiling-card-form { + display: flex; + flex-direction: column; + gap: 20px; +} diff --git a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx b/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx new file mode 100644 index 0000000..2d7f839 --- /dev/null +++ b/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx @@ -0,0 +1,225 @@ +import { Button, FormInput, FormFieldValue, Card } from "@shared/ui"; +import { FormSection, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; +import classNames from "classnames/bind"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { usePluginContext } from "../../plugin"; +import { FormInputComponentMap } from "../../types"; + +import styles from "./profiling-card.module.css"; + +const cx = classNames.bind(styles); + +interface ProfilingCardProps { + sections: FormSection[]; + data: ProfileFormData; + onSubmit: (data: ProfileFormData) => Promise<{ status: "OK" } | { status: "ERROR"; message: string }>; + onSuccess: () => void; + isLoading: boolean; + fetchFormData: () => Promise<{ status: "OK"; data: ProfileFormData } | { status: "ERROR"; message: string }>; + componentMap: FormInputComponentMap; +} + +export const ProfilingCard = ({ data, onSubmit, onSuccess, isLoading, ...props }: ProfilingCardProps) => { + const { t } = usePluginContext(); + + const sections = useMemo(() => { + return [ + { + id: "profile-start", + label: t("PL_PP_SECTION_PROFILE_START_LABEL"), + description: t("PL_PP_SECTION_PROFILE_START_DESCRIPTION"), + completed: true, + fields: [], + }, + ...props.sections, + { + id: "profile-end", + label: t("PL_PP_SECTION_PROFILE_END_LABEL"), + description: t("PL_PP_SECTION_PROFILE_END_DESCRIPTION"), + completed: true, + fields: [], + }, + ]; + }, [props.sections]); + + const startingSectionIndex = useMemo(() => { + const index = sections.findIndex((section) => !section.completed); + return index === -1 ? 0 : index; + }, [sections]); + + const [activeSectionIndex, setActiveSectionIndex] = useState(startingSectionIndex); + const [editingProfileDetails, setEditingProfileDetails] = useState>({}); + + const isComplete = useMemo(() => { + return sections.every((section) => section.completed); + }, [sections]); + + const isLastSection = useMemo(() => { + return activeSectionIndex === sections.length - 1; + }, [activeSectionIndex, sections]); + + const currentSection = useMemo(() => { + if (activeSectionIndex === -1) { + return null; + } + + return sections[activeSectionIndex]; + }, [sections, activeSectionIndex]); + + useEffect(() => { + setEditingProfileDetails( + data.reduce( + (acc, item) => { + acc[item.fieldId] = item.value; + return acc; + }, + {} as Record, + ), + ); + }, [data]); + + const moveToNextSection = useCallback( + (currentSectionIndex: number) => { + if (currentSectionIndex === -1) { + return; + } + if (currentSectionIndex === sections.length - 1) { + return; + } + + setActiveSectionIndex(currentSectionIndex + 1); + }, + [sections], + ); + + const moveToSection = useCallback( + (sectionIndex: number) => { + if (sectionIndex < 0) { + return; + } + if (sectionIndex >= sections.length) { + return; + } + if (!isSectionEnabled(sectionIndex)) { + return; + } + + setActiveSectionIndex(sectionIndex); + }, + [sections], + ); + + const moveToNextSectionEnabled = useMemo(() => { + return (isComplete && activeSectionIndex === sections.length - 1) || activeSectionIndex < sections.length - 1; + }, [isComplete, activeSectionIndex, sections]); + + const moveToNextSectionLabel = useMemo(() => { + if (activeSectionIndex === 0) { + return t("PL_PP_SECTION_NEXT_BUTTON"); + } + if (activeSectionIndex === sections.length - 1) { + return t("PL_PP_SECTION_COMPLETE_BUTTON"); + } + return t("PL_PP_SECTION_SAVE_AND_NEXT_BUTTON"); + }, [sections, activeSectionIndex]); + + const isSectionEnabled = useCallback( + (sectionIndex: number) => { + const section = sections[sectionIndex - 1]; + if (!section) { + return true; + } // the first section is always enabled + + return section.completed; + }, + [sections], + ); + + const handleSubmit = useCallback(async () => { + if (!currentSection) { + return; + } + + const data: ProfileFormData = Object.entries(editingProfileDetails).map(([key, value]) => { + return { sectionId: currentSection.id, fieldId: key, value: value }; + }); + + const result = await onSubmit(data); + if (result.status === "ERROR") { + console.error(result); + } else if (isLastSection && result.status === "OK") { + onSuccess(); + } else { + moveToNextSection(activeSectionIndex); + } + }, [ + currentSection, + isLastSection, + onSubmit, + onSuccess, + moveToNextSection, + activeSectionIndex, + editingProfileDetails, + ]); + + const handleInputChange = useCallback( + (field: string, value: any) => { + setEditingProfileDetails({ + ...editingProfileDetails, + [field]: value, + }); + }, + [editingProfileDetails], + ); + + useEffect(() => { + if (isComplete) { + onSuccess(); + } + }, []); + + if (isLoading) { + return ; + } + + if (!currentSection) { + return ; + } + + return ( +
+
+ {sections.map((section, index) => ( +
moveToSection(index)}> + {index + 1} +
+ ))} +
+ + +
+ {currentSection.fields.map((field) => ( + handleInputChange(field.id, value)} + componentMap={props.componentMap} + {...field} + /> + ))} + + + +
+
+ ); +}; diff --git a/packages/progressive-profiling-react/src/constants.ts b/packages/progressive-profiling-react/src/constants.ts new file mode 100644 index 0000000..08189ba --- /dev/null +++ b/packages/progressive-profiling-react/src/constants.ts @@ -0,0 +1,38 @@ +import { + StringFieldComponent, + TextFieldComponent, + NumberFieldComponent, + BooleanFieldComponent, + EmailFieldComponent, + PhoneFieldComponent, + DateFieldComponent, + SelectFieldComponent, + MultiselectFieldComponent, + PasswordFieldComponent, + UrlFieldComponent, + ImageUrlFieldComponent, + ToggleInput, +} from "@shared/ui"; + +import { FormInputComponentMap } from "./types"; + +export const PLUGIN_ID = "supertokens-plugin-progressive-profiling"; +export const PLUGIN_VERSION = "0.0.1"; + +export const API_PATH = `plugin/${PLUGIN_ID}`; + +export const FIELD_TYPE_COMPONENT_MAP: FormInputComponentMap = { + string: StringFieldComponent, + text: TextFieldComponent, + number: NumberFieldComponent, + boolean: BooleanFieldComponent, + toggle: ToggleInput, + email: EmailFieldComponent, + phone: PhoneFieldComponent, + date: DateFieldComponent, + select: SelectFieldComponent, + multiselect: MultiselectFieldComponent, + password: PasswordFieldComponent, + url: UrlFieldComponent, + "image-url": ImageUrlFieldComponent, +} as const; diff --git a/packages/progressive-profiling-react/src/css.d.ts b/packages/progressive-profiling-react/src/css.d.ts new file mode 100644 index 0000000..93c8235 --- /dev/null +++ b/packages/progressive-profiling-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/progressive-profiling-react/src/index.ts b/packages/progressive-profiling-react/src/index.ts new file mode 100644 index 0000000..0444aac --- /dev/null +++ b/packages/progressive-profiling-react/src/index.ts @@ -0,0 +1,12 @@ +import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; +import { init, usePluginContext } from "./plugin"; +import { UserProfileWrapper } from "./user-profile-wrapper"; + +export { init, usePluginContext, PLUGIN_ID, PLUGIN_VERSION, UserProfileWrapper }; +export default { + init, + usePluginContext, + PLUGIN_ID, + PLUGIN_VERSION, + UserProfileWrapper, +}; diff --git a/packages/progressive-profiling-react/src/logger.ts b/packages/progressive-profiling-react/src/logger.ts new file mode 100644 index 0000000..37cdd77 --- /dev/null +++ b/packages/progressive-profiling-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/progressive-profiling-react/src/plugin.ts b/packages/progressive-profiling-react/src/plugin.ts new file mode 100644 index 0000000..d9d2086 --- /dev/null +++ b/packages/progressive-profiling-react/src/plugin.ts @@ -0,0 +1,93 @@ +import { createPluginInitFunction } from "@shared/js"; +import { buildContext, getQuerier } from "@shared/react"; +import { getTranslationFunction, SuperTokensPlugin } from "supertokens-auth-react"; +import { BooleanClaim } from "supertokens-auth-react/recipe/session"; + +import { getApi } from "./api"; +import { API_PATH, FIELD_TYPE_COMPONENT_MAP, PLUGIN_ID } from "./constants"; +import { enableDebugLogs } from "./logger"; +import { SetupProfilePage } from "./setup-profile-page"; +import { defaultTranslationsProgressiveProfiling } from "./translations"; +import { + SuperTokensPluginProfileProgressiveProfilingConfig, + SuperTokensPluginProfileProgressiveProfilingImplementation, + FormInputComponentMap, + TranslationKeys, +} from "./types"; + +const { usePluginContext, setContext } = buildContext<{ + pluginConfig: SuperTokensPluginProfileProgressiveProfilingConfig; + componentMap: FormInputComponentMap; + querier: ReturnType; + api: ReturnType; + t: (key: TranslationKeys) => string; +}>(); +export { usePluginContext }; + +export const init = createPluginInitFunction< + SuperTokensPlugin, + SuperTokensPluginProfileProgressiveProfilingConfig, + SuperTokensPluginProfileProgressiveProfilingImplementation +>( + (pluginConfig, implementation) => { + const componentMap = implementation.componentMap(FIELD_TYPE_COMPONENT_MAP); + + const ProgressiveProfilingCompletedClaim = new BooleanClaim({ + id: `${PLUGIN_ID}-completed`, + refresh: async () => {}, + onFailureRedirection: async ({ reason }) => { + return "/user/setup"; + }, + }); + + return { + id: PLUGIN_ID, + init: (config) => { + if (config.enableDebugLogs) { + enableDebugLogs(); + } + + const querier = getQuerier(new URL(API_PATH, config.appInfo.apiDomain.getAsStringDangerous()).toString()); + const api = getApi(querier); + const t = getTranslationFunction(defaultTranslationsProgressiveProfiling); + + setContext({ + pluginConfig, + componentMap, + querier, + api, + t, + }); + }, + routeHandlers: (appConfig: any, plugins: any, sdkVersion: any) => { + return { + status: "OK", + routeHandlers: [ + { + path: "/user/setup", + handler: () => SetupProfilePage.call(null), + }, + ], + }; + }, + overrideMap: { + session: { + functions: (originalImplementation) => { + return { + ...originalImplementation, + getGlobalClaimValidators(input) { + return [ + ...input.claimValidatorsAddedByOtherRecipes, + ProgressiveProfilingCompletedClaim.validators.isTrue(), + ]; + }, + }; + }, + }, + }, + }; + }, + { + componentMap: (originalImplementation) => originalImplementation, + }, +); diff --git a/packages/progressive-profiling-react/src/setup-profile-page.tsx b/packages/progressive-profiling-react/src/setup-profile-page.tsx new file mode 100644 index 0000000..7a1c0d2 --- /dev/null +++ b/packages/progressive-profiling-react/src/setup-profile-page.tsx @@ -0,0 +1,21 @@ +import { ThemeProvider } from "@shared/ui"; +import { SuperTokensWrapper } from "supertokens-auth-react"; +import { SessionAuth } from "supertokens-auth-react/recipe/session"; + +import { PageWrapper } from "./components"; +import { UserProfileWrapper } from "./user-profile-wrapper"; + +export const SetupProfilePage = () => { + return ( + + + {/* The theme provider is needed here, because this plugin does not use the base plugin (that has the theme provider) */} + + + + + + + + ); +}; diff --git a/packages/progressive-profiling-react/src/translations.ts b/packages/progressive-profiling-react/src/translations.ts new file mode 100644 index 0000000..6affe32 --- /dev/null +++ b/packages/progressive-profiling-react/src/translations.ts @@ -0,0 +1,16 @@ +export const defaultTranslationsProgressiveProfiling = { + en: { + PL_PP_NO_SECTIONS: "No sections found", + PL_PP_SECTION_PROFILE_START_LABEL: "Profile", + PL_PP_SECTION_PROFILE_START_DESCRIPTION: + "Before advancing any further, you will have to go through a quick x step process for setting up your profile. This is only done once.", + PL_PP_SECTION_PROFILE_END_LABEL: "Profile", + PL_PP_SECTION_PROFILE_END_DESCRIPTION: + "You have completed the profile setup. You can now advance to the next step.", + PL_PP_SECTION_NEXT_BUTTON: "Next", + PL_PP_SECTION_SAVE_AND_NEXT_BUTTON: "Save & Next", + PL_PP_SECTION_COMPLETE_BUTTON: "Complete", + PL_PP_LOADING: "Loading...", + PL_PP_PROFILE_SETUP_NOT_AVAILABLE: "The profile setup is not available", + }, +} as const; diff --git a/packages/progressive-profiling-react/src/types.ts b/packages/progressive-profiling-react/src/types.ts new file mode 100644 index 0000000..3bd061d --- /dev/null +++ b/packages/progressive-profiling-react/src/types.ts @@ -0,0 +1,12 @@ +import { BaseInput, FormFieldType } from "@shared/ui"; + +import { defaultTranslationsProgressiveProfiling } from "./translations"; + +export type SuperTokensPluginProfileProgressiveProfilingConfig = undefined; + +export type SuperTokensPluginProfileProgressiveProfilingImplementation = { + componentMap: (componentMap: FormInputComponentMap) => FormInputComponentMap; +}; + +export type TranslationKeys = keyof (typeof defaultTranslationsProgressiveProfiling)["en"]; +export type FormInputComponentMap = Record>>; diff --git a/packages/progressive-profiling-react/src/user-profile-wrapper.tsx b/packages/progressive-profiling-react/src/user-profile-wrapper.tsx new file mode 100644 index 0000000..376aa96 --- /dev/null +++ b/packages/progressive-profiling-react/src/user-profile-wrapper.tsx @@ -0,0 +1,87 @@ +import { ToastContainer, ToastProvider } from "@shared/ui"; +import { FormSection, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; +import { useCallback, useEffect, useState } from "react"; + +import { ProfilingCard } from "./components"; +import { usePluginContext } from "./plugin"; + +export const UserProfileWrapper = () => { + const { api, componentMap, t } = usePluginContext(); + + const [isLoading, setIsLoading] = useState(true); + const [sections, setSections] = useState([]); + const [data, setData] = useState([]); + + const loadSections = useCallback(async () => { + setIsLoading(true); + let response: Awaited>; + try { + response = await api.getSections(); + } finally { + setIsLoading(false); + } + + if (response.status === "OK") { + setSections(response.sections); + } + + return response; + }, []); + + const loadProfile = useCallback(async () => { + setIsLoading(true); + let response: Awaited>; + try { + response = await api.getProfile(); + } finally { + setIsLoading(false); + } + + if (response.status === "OK") { + setData(response.data); + } + + return response; + }, []); + + const onSubmit = useCallback(async (data: ProfileFormData) => { + setIsLoading(true); + let response: Awaited>; + try { + response = await api.updateProfile({ data }); + } finally { + setIsLoading(false); + } + + return response; + }, []); + + const onSuccess = useCallback(async () => { + console.log("onSuccess"); + // window.location.href = "/"; + }, []); + + useEffect(() => { + loadSections(); + loadProfile(); + }, []); + + if (sections.length === 0) { + return
{t("PL_PP_NO_SECTIONS")}
; // add empty state and/or redirect + } + + return ( + + + + + ); +}; diff --git a/packages/progressive-profiling-react/tsconfig.json b/packages/progressive-profiling-react/tsconfig.json new file mode 100644 index 0000000..9ce578d --- /dev/null +++ b/packages/progressive-profiling-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/progressive-profiling-react/vite.config.ts b/packages/progressive-profiling-react/vite.config.ts new file mode 100644 index 0000000..03cff1c --- /dev/null +++ b/packages/progressive-profiling-react/vite.config.ts @@ -0,0 +1,40 @@ +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/packages/progressive-profiling-shared/.prettierrc.js b/packages/progressive-profiling-shared/.prettierrc.js new file mode 100644 index 0000000..8986fc5 --- /dev/null +++ b/packages/progressive-profiling-shared/.prettierrc.js @@ -0,0 +1,4 @@ +/** @type {import("prettier").Config} */ +module.exports = { + ...require("@shared/eslint/prettier"), +}; diff --git a/packages/progressive-profiling-shared/README.md b/packages/progressive-profiling-shared/README.md new file mode 100644 index 0000000..9403684 --- /dev/null +++ b/packages/progressive-profiling-shared/README.md @@ -0,0 +1 @@ +# shared diff --git a/packages/progressive-profiling-shared/package.json b/packages/progressive-profiling-shared/package.json new file mode 100644 index 0000000..613c43b --- /dev/null +++ b/packages/progressive-profiling-shared/package.json @@ -0,0 +1,14 @@ +{ + "name": "@supertokens-plugins/progressive-profiling-shared", + "version": "0.0.1", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "peerDependencies": {}, + "devDependencies": { + "@shared/eslint": "*", + "@shared/tsconfig": "*" + }, + "dependencies": {} +} diff --git a/packages/progressive-profiling-shared/src/index.ts b/packages/progressive-profiling-shared/src/index.ts new file mode 100644 index 0000000..eea524d --- /dev/null +++ b/packages/progressive-profiling-shared/src/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/packages/progressive-profiling-shared/src/types.ts b/packages/progressive-profiling-shared/src/types.ts new file mode 100644 index 0000000..dcab7f0 --- /dev/null +++ b/packages/progressive-profiling-shared/src/types.ts @@ -0,0 +1,34 @@ +export type FormFieldValue = string | number | boolean | null | undefined | string[]; + +export type FormField = { + id: string; + label: string; + type: + | "string" + | "text" + | "number" + | "boolean" + | "email" + | "phone" + | "date" + | "select" + | "multiselect" + | "password" + | "url" + | "image-url"; + required: boolean; + defaultValue?: FormFieldValue; + placeholder?: string; + description?: string; + options?: { value: FormFieldValue; label: string }[]; +}; + +export type FormSection = { + id: string; + label: string; + description?: string; + fields: FormField[]; + completed: boolean; +}; + +export type ProfileFormData = { sectionId: string; fieldId: string; value: FormFieldValue }[]; diff --git a/packages/progressive-profiling-shared/tsconfig.json b/packages/progressive-profiling-shared/tsconfig.json new file mode 100644 index 0000000..246bfd9 --- /dev/null +++ b/packages/progressive-profiling-shared/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@shared/tsconfig/base.json", + "compilerOptions": { + "lib": ["ES2020"], + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist", + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} From 9a0ba05a46e4f14b767efcaa6a97eaf0a9b19885 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 28 Aug 2025 11:09:27 +0300 Subject: [PATCH 03/46] styling and translation fixes --- .../profiling-card/profiling-card.module.css | 15 ++++++++------- .../components/profiling-card/profiling-card.tsx | 6 +++--- .../progressive-profiling-react/src/plugin.ts | 2 +- .../src/translations.ts | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css b/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css index dd0e5d6..063857e 100644 --- a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css +++ b/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css @@ -16,15 +16,16 @@ line-height: 36px; font-size: 14px; transition: background-color 0.2s; - background-color: white; - border: 1px solid #07c; - color: #07c; + border: 1px solid var(--wa-color-brand-fill-loud); + background-color: transparent; + color: var(--wa-color-brand-on-quiet); } .profiling-card-bullets .profiling-card-bullet.active { font-weight: 700; - background-color: #07c; - color: #fff; + background-color: var(--wa-color-brand-fill-loud); + color: var(--wa-color-brand-on-loud); + border-color: var(--wa-color-brand-fill-loud); } .profiling-card-bullets .profiling-card-bullet.disabled { opacity: 0.5; @@ -32,8 +33,8 @@ } .profiling-card-bullets .profiling-card-bullet:hover { - background-color: #005fa3; - color: #fff; + background-color: var(--st-color-brand-fill-loud-hover); + color: var(--wa-color-brand-on-loud); } .profiling-card-form { diff --git a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx b/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx index 2d7f839..beee7fa 100644 --- a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx +++ b/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx @@ -28,7 +28,7 @@ export const ProfilingCard = ({ data, onSubmit, onSuccess, isLoading, ...props } { id: "profile-start", label: t("PL_PP_SECTION_PROFILE_START_LABEL"), - description: t("PL_PP_SECTION_PROFILE_START_DESCRIPTION"), + description: t("PL_PP_SECTION_PROFILE_START_DESCRIPTION", { steps: (props.sections.length + 2).toString() }), completed: true, fields: [], }, @@ -37,7 +37,7 @@ export const ProfilingCard = ({ data, onSubmit, onSuccess, isLoading, ...props } id: "profile-end", label: t("PL_PP_SECTION_PROFILE_END_LABEL"), description: t("PL_PP_SECTION_PROFILE_END_DESCRIPTION"), - completed: true, + completed: false, fields: [], }, ]; @@ -215,7 +215,7 @@ export const ProfilingCard = ({ data, onSubmit, onSuccess, isLoading, ...props } /> ))} - diff --git a/packages/progressive-profiling-react/src/plugin.ts b/packages/progressive-profiling-react/src/plugin.ts index d9d2086..192ed29 100644 --- a/packages/progressive-profiling-react/src/plugin.ts +++ b/packages/progressive-profiling-react/src/plugin.ts @@ -20,7 +20,7 @@ const { usePluginContext, setContext } = buildContext<{ componentMap: FormInputComponentMap; querier: ReturnType; api: ReturnType; - t: (key: TranslationKeys) => string; + t: (key: TranslationKeys, params?: Record) => string; }>(); export { usePluginContext }; diff --git a/packages/progressive-profiling-react/src/translations.ts b/packages/progressive-profiling-react/src/translations.ts index 6affe32..bf5c181 100644 --- a/packages/progressive-profiling-react/src/translations.ts +++ b/packages/progressive-profiling-react/src/translations.ts @@ -3,7 +3,7 @@ export const defaultTranslationsProgressiveProfiling = { PL_PP_NO_SECTIONS: "No sections found", PL_PP_SECTION_PROFILE_START_LABEL: "Profile", PL_PP_SECTION_PROFILE_START_DESCRIPTION: - "Before advancing any further, you will have to go through a quick x step process for setting up your profile. This is only done once.", + "Before advancing any further, you will have to go through a quick {steps} step process for setting up your profile. This is only done once.", PL_PP_SECTION_PROFILE_END_LABEL: "Profile", PL_PP_SECTION_PROFILE_END_DESCRIPTION: "You have completed the profile setup. You can now advance to the next step.", From 087a06f0071a18d248b262825c7a876635c0788a Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 28 Aug 2025 13:08:25 +0300 Subject: [PATCH 04/46] refactor: rename and restructure progressive profiling components and add more configuration --- .../src/components/index.ts | 2 +- .../src/components/profiling-card/index.ts | 1 - .../progressive-profiling-form/index.ts | 1 + .../progressive-profiling-form.module.css} | 12 ++--- .../progressive-profiling-form.tsx} | 24 ++++++---- .../src/constants.ts | 8 +++- .../progressive-profiling-react/src/index.ts | 6 +-- .../progressive-profiling-react/src/plugin.ts | 45 ++++++++++++------- ...x => progressive-profiling-setup-page.tsx} | 6 +-- ....tsx => progressive-profiling-wrapper.tsx} | 15 +++---- .../progressive-profiling-react/src/types.ts | 9 +++- 11 files changed, 78 insertions(+), 51 deletions(-) delete mode 100644 packages/progressive-profiling-react/src/components/profiling-card/index.ts create mode 100644 packages/progressive-profiling-react/src/components/progressive-profiling-form/index.ts rename packages/progressive-profiling-react/src/components/{profiling-card/profiling-card.module.css => progressive-profiling-form/progressive-profiling-form.module.css} (68%) rename packages/progressive-profiling-react/src/components/{profiling-card/profiling-card.tsx => progressive-profiling-form/progressive-profiling-form.tsx} (91%) rename packages/progressive-profiling-react/src/{setup-profile-page.tsx => progressive-profiling-setup-page.tsx} (77%) rename packages/progressive-profiling-react/src/{user-profile-wrapper.tsx => progressive-profiling-wrapper.tsx} (85%) diff --git a/packages/progressive-profiling-react/src/components/index.ts b/packages/progressive-profiling-react/src/components/index.ts index 415cfd0..26a8c5c 100644 --- a/packages/progressive-profiling-react/src/components/index.ts +++ b/packages/progressive-profiling-react/src/components/index.ts @@ -1,2 +1,2 @@ -export * from "./profiling-card"; +export * from "./progressive-profiling-form"; export * from "./page-wrapper"; diff --git a/packages/progressive-profiling-react/src/components/profiling-card/index.ts b/packages/progressive-profiling-react/src/components/profiling-card/index.ts deleted file mode 100644 index d400f19..0000000 --- a/packages/progressive-profiling-react/src/components/profiling-card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./profiling-card"; diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/index.ts b/packages/progressive-profiling-react/src/components/progressive-profiling-form/index.ts new file mode 100644 index 0000000..c2dc456 --- /dev/null +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/index.ts @@ -0,0 +1 @@ +export * from "./progressive-profiling-form"; diff --git a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css similarity index 68% rename from packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css rename to packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css index 063857e..e6abb9e 100644 --- a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.module.css +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css @@ -1,4 +1,4 @@ -.profiling-card-bullets { +.progressive-profiling-form-bullets { display: flex; align-items: center; justify-content: space-evenly; @@ -7,7 +7,7 @@ margin-bottom: var(--plugin-spacing-2xl); } -.profiling-card-bullets .profiling-card-bullet { +.progressive-profiling-form-bullets .progressive-profiling-form-bullet { width: 36px; height: 36px; border-radius: 50%; @@ -21,23 +21,23 @@ color: var(--wa-color-brand-on-quiet); } -.profiling-card-bullets .profiling-card-bullet.active { +.progressive-profiling-form-bullets .progressive-profiling-form-bullet.active { font-weight: 700; background-color: var(--wa-color-brand-fill-loud); color: var(--wa-color-brand-on-loud); border-color: var(--wa-color-brand-fill-loud); } -.profiling-card-bullets .profiling-card-bullet.disabled { +.progressive-profiling-form-bullets .progressive-profiling-form-bullet.disabled { opacity: 0.5; cursor: not-allowed; } -.profiling-card-bullets .profiling-card-bullet:hover { +.progressive-profiling-form-bullets .progressive-profiling-form-bullet:hover { background-color: var(--st-color-brand-fill-loud-hover); color: var(--wa-color-brand-on-loud); } -.profiling-card-form { +.progressive-profiling-form-form { display: flex; flex-direction: column; gap: 20px; diff --git a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx similarity index 91% rename from packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx rename to packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index beee7fa..6fbcd73 100644 --- a/packages/progressive-profiling-react/src/components/profiling-card/profiling-card.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -6,21 +6,27 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { usePluginContext } from "../../plugin"; import { FormInputComponentMap } from "../../types"; -import styles from "./profiling-card.module.css"; +import styles from "./progressive-profiling-form.module.css"; const cx = classNames.bind(styles); -interface ProfilingCardProps { +interface ProgressiveProfilingFormProps { sections: FormSection[]; data: ProfileFormData; onSubmit: (data: ProfileFormData) => Promise<{ status: "OK" } | { status: "ERROR"; message: string }>; - onSuccess: () => void; + onSuccess: (data: ProfileFormData) => Promise; isLoading: boolean; fetchFormData: () => Promise<{ status: "OK"; data: ProfileFormData } | { status: "ERROR"; message: string }>; componentMap: FormInputComponentMap; } -export const ProfilingCard = ({ data, onSubmit, onSuccess, isLoading, ...props }: ProfilingCardProps) => { +export const ProgressiveProfilingForm = ({ + data, + onSubmit, + onSuccess, + isLoading, + ...props +}: ProgressiveProfilingFormProps) => { const { t } = usePluginContext(); const sections = useMemo(() => { @@ -149,7 +155,7 @@ export const ProfilingCard = ({ data, onSubmit, onSuccess, isLoading, ...props } if (result.status === "ERROR") { console.error(result); } else if (isLastSection && result.status === "OK") { - onSuccess(); + await onSuccess(data); } else { moveToNextSection(activeSectionIndex); } @@ -175,7 +181,7 @@ export const ProfilingCard = ({ data, onSubmit, onSuccess, isLoading, ...props } useEffect(() => { if (isComplete) { - onSuccess(); + onSuccess(data); } }, []); @@ -189,11 +195,11 @@ export const ProfilingCard = ({ data, onSubmit, onSuccess, isLoading, ...props } return (
-
+
{sections.map((section, index) => (
-
+ {currentSection.fields.map((field) => ( { + window.location.href = "/"; +}; diff --git a/packages/progressive-profiling-react/src/index.ts b/packages/progressive-profiling-react/src/index.ts index 0444aac..12b5f17 100644 --- a/packages/progressive-profiling-react/src/index.ts +++ b/packages/progressive-profiling-react/src/index.ts @@ -1,12 +1,12 @@ import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; import { init, usePluginContext } from "./plugin"; -import { UserProfileWrapper } from "./user-profile-wrapper"; +import { ProgressiveProfilingWrapper } from "./progressive-profiling-wrapper"; -export { init, usePluginContext, PLUGIN_ID, PLUGIN_VERSION, UserProfileWrapper }; +export { init, usePluginContext, PLUGIN_ID, PLUGIN_VERSION, ProgressiveProfilingWrapper as UserProfileWrapper }; export default { init, usePluginContext, PLUGIN_ID, PLUGIN_VERSION, - UserProfileWrapper, + UserProfileWrapper: ProgressiveProfilingWrapper, }; diff --git a/packages/progressive-profiling-react/src/plugin.ts b/packages/progressive-profiling-react/src/plugin.ts index 192ed29..e7928b0 100644 --- a/packages/progressive-profiling-react/src/plugin.ts +++ b/packages/progressive-profiling-react/src/plugin.ts @@ -4,9 +4,16 @@ import { getTranslationFunction, SuperTokensPlugin } from "supertokens-auth-reac import { BooleanClaim } from "supertokens-auth-react/recipe/session"; import { getApi } from "./api"; -import { API_PATH, FIELD_TYPE_COMPONENT_MAP, PLUGIN_ID } from "./constants"; +import { + API_PATH, + DEFAULT_FIELD_TYPE_COMPONENT_MAP, + DEFAULT_ON_SUCCESS, + DEFAULT_REQUIRE_SETUP, + DEFAULT_SETUP_PAGE_PATH, + PLUGIN_ID, +} from "./constants"; import { enableDebugLogs } from "./logger"; -import { SetupProfilePage } from "./setup-profile-page"; +import { ProgressiveProfilingSetupPage } from "./progressive-profiling-setup-page"; import { defaultTranslationsProgressiveProfiling } from "./translations"; import { SuperTokensPluginProfileProgressiveProfilingConfig, @@ -20,23 +27,24 @@ const { usePluginContext, setContext } = buildContext<{ componentMap: FormInputComponentMap; querier: ReturnType; api: ReturnType; - t: (key: TranslationKeys, params?: Record) => string; + t: (key: TranslationKeys, replacements?: Record) => string; }>(); export { usePluginContext }; export const init = createPluginInitFunction< SuperTokensPlugin, SuperTokensPluginProfileProgressiveProfilingConfig, - SuperTokensPluginProfileProgressiveProfilingImplementation + SuperTokensPluginProfileProgressiveProfilingImplementation, + Required >( (pluginConfig, implementation) => { - const componentMap = implementation.componentMap(FIELD_TYPE_COMPONENT_MAP); + const componentMap = implementation.componentMap(); const ProgressiveProfilingCompletedClaim = new BooleanClaim({ id: `${PLUGIN_ID}-completed`, refresh: async () => {}, - onFailureRedirection: async ({ reason }) => { - return "/user/setup"; + onFailureRedirection: async () => { + return pluginConfig.setupPagePath; }, }); @@ -59,13 +67,13 @@ export const init = createPluginInitFunction< t, }); }, - routeHandlers: (appConfig: any, plugins: any, sdkVersion: any) => { + routeHandlers: () => { return { status: "OK", routeHandlers: [ { - path: "/user/setup", - handler: () => SetupProfilePage.call(null), + path: pluginConfig.setupPagePath, + handler: () => ProgressiveProfilingSetupPage.call(null), }, ], }; @@ -76,10 +84,12 @@ export const init = createPluginInitFunction< return { ...originalImplementation, getGlobalClaimValidators(input) { - return [ - ...input.claimValidatorsAddedByOtherRecipes, - ProgressiveProfilingCompletedClaim.validators.isTrue(), - ]; + return pluginConfig.requireSetup + ? [ + ...input.claimValidatorsAddedByOtherRecipes, + ProgressiveProfilingCompletedClaim.validators.isTrue(), + ] + : input.claimValidatorsAddedByOtherRecipes; }, }; }, @@ -88,6 +98,11 @@ export const init = createPluginInitFunction< }; }, { - componentMap: (originalImplementation) => originalImplementation, + componentMap: () => DEFAULT_FIELD_TYPE_COMPONENT_MAP, }, + (config) => ({ + onSuccess: config.onSuccess ?? DEFAULT_ON_SUCCESS, + requireSetup: config.requireSetup ?? DEFAULT_REQUIRE_SETUP, + setupPagePath: config.setupPagePath ?? DEFAULT_SETUP_PAGE_PATH, + }), ); diff --git a/packages/progressive-profiling-react/src/setup-profile-page.tsx b/packages/progressive-profiling-react/src/progressive-profiling-setup-page.tsx similarity index 77% rename from packages/progressive-profiling-react/src/setup-profile-page.tsx rename to packages/progressive-profiling-react/src/progressive-profiling-setup-page.tsx index 7a1c0d2..a8f8ec1 100644 --- a/packages/progressive-profiling-react/src/setup-profile-page.tsx +++ b/packages/progressive-profiling-react/src/progressive-profiling-setup-page.tsx @@ -3,16 +3,16 @@ import { SuperTokensWrapper } from "supertokens-auth-react"; import { SessionAuth } from "supertokens-auth-react/recipe/session"; import { PageWrapper } from "./components"; -import { UserProfileWrapper } from "./user-profile-wrapper"; +import { ProgressiveProfilingWrapper } from "./progressive-profiling-wrapper"; -export const SetupProfilePage = () => { +export const ProgressiveProfilingSetupPage = () => { return ( {/* The theme provider is needed here, because this plugin does not use the base plugin (that has the theme provider) */} - + diff --git a/packages/progressive-profiling-react/src/user-profile-wrapper.tsx b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx similarity index 85% rename from packages/progressive-profiling-react/src/user-profile-wrapper.tsx rename to packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx index 376aa96..31449ff 100644 --- a/packages/progressive-profiling-react/src/user-profile-wrapper.tsx +++ b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx @@ -2,11 +2,11 @@ import { ToastContainer, ToastProvider } from "@shared/ui"; import { FormSection, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; import { useCallback, useEffect, useState } from "react"; -import { ProfilingCard } from "./components"; +import { ProgressiveProfilingForm } from "./components"; import { usePluginContext } from "./plugin"; -export const UserProfileWrapper = () => { - const { api, componentMap, t } = usePluginContext(); +export const ProgressiveProfilingWrapper = () => { + const { api, componentMap, t, pluginConfig } = usePluginContext(); const [isLoading, setIsLoading] = useState(true); const [sections, setSections] = useState([]); @@ -56,11 +56,6 @@ export const UserProfileWrapper = () => { return response; }, []); - const onSuccess = useCallback(async () => { - console.log("onSuccess"); - // window.location.href = "/"; - }, []); - useEffect(() => { loadSections(); loadProfile(); @@ -72,13 +67,13 @@ export const UserProfileWrapper = () => { return ( - diff --git a/packages/progressive-profiling-react/src/types.ts b/packages/progressive-profiling-react/src/types.ts index 3bd061d..6904418 100644 --- a/packages/progressive-profiling-react/src/types.ts +++ b/packages/progressive-profiling-react/src/types.ts @@ -1,11 +1,16 @@ import { BaseInput, FormFieldType } from "@shared/ui"; +import { ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; import { defaultTranslationsProgressiveProfiling } from "./translations"; -export type SuperTokensPluginProfileProgressiveProfilingConfig = undefined; +export type SuperTokensPluginProfileProgressiveProfilingConfig = { + setupPagePath?: string; + requireSetup?: boolean; + onSuccess: (data: ProfileFormData) => Promise; +}; export type SuperTokensPluginProfileProgressiveProfilingImplementation = { - componentMap: (componentMap: FormInputComponentMap) => FormInputComponentMap; + componentMap: () => FormInputComponentMap; }; export type TranslationKeys = keyof (typeof defaultTranslationsProgressiveProfiling)["en"]; From efc29c8fb368818fbdcfc1468eac136a5cb2814e Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 28 Aug 2025 17:06:23 +0300 Subject: [PATCH 05/46] frontend pr fixes and cleanup --- .../progressive-profiling-form.tsx | 65 +++++++------------ .../src/progressive-profiling-wrapper.tsx | 5 ++ 2 files changed, 28 insertions(+), 42 deletions(-) diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index 6fbcd73..1bcbc55 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -35,7 +35,7 @@ export const ProgressiveProfilingForm = ({ id: "profile-start", label: t("PL_PP_SECTION_PROFILE_START_LABEL"), description: t("PL_PP_SECTION_PROFILE_START_DESCRIPTION", { steps: (props.sections.length + 2).toString() }), - completed: true, + completed: false, fields: [], }, ...props.sections, @@ -55,15 +55,13 @@ export const ProgressiveProfilingForm = ({ }, [sections]); const [activeSectionIndex, setActiveSectionIndex] = useState(startingSectionIndex); - const [editingProfileDetails, setEditingProfileDetails] = useState>({}); + const [profileDetails, setProfileDetails] = useState>({}); const isComplete = useMemo(() => { return sections.every((section) => section.completed); }, [sections]); - const isLastSection = useMemo(() => { - return activeSectionIndex === sections.length - 1; - }, [activeSectionIndex, sections]); + const isLastSection = activeSectionIndex === sections.length - 1; const currentSection = useMemo(() => { if (activeSectionIndex === -1) { @@ -73,18 +71,6 @@ export const ProgressiveProfilingForm = ({ return sections[activeSectionIndex]; }, [sections, activeSectionIndex]); - useEffect(() => { - setEditingProfileDetails( - data.reduce( - (acc, item) => { - acc[item.fieldId] = item.value; - return acc; - }, - {} as Record, - ), - ); - }, [data]); - const moveToNextSection = useCallback( (currentSectionIndex: number) => { if (currentSectionIndex === -1) { @@ -147,7 +133,7 @@ export const ProgressiveProfilingForm = ({ return; } - const data: ProfileFormData = Object.entries(editingProfileDetails).map(([key, value]) => { + const data: ProfileFormData = Object.entries(profileDetails).map(([key, value]) => { return { sectionId: currentSection.id, fieldId: key, value: value }; }); @@ -159,32 +145,27 @@ export const ProgressiveProfilingForm = ({ } else { moveToNextSection(activeSectionIndex); } - }, [ - currentSection, - isLastSection, - onSubmit, - onSuccess, - moveToNextSection, - activeSectionIndex, - editingProfileDetails, - ]); - - const handleInputChange = useCallback( - (field: string, value: any) => { - setEditingProfileDetails({ - ...editingProfileDetails, - [field]: value, - }); - }, - [editingProfileDetails], - ); + }, [currentSection, isLastSection, onSubmit, onSuccess, moveToNextSection, activeSectionIndex, profileDetails]); - useEffect(() => { - if (isComplete) { - onSuccess(data); - } + const handleInputChange = useCallback((field: string, value: any) => { + setProfileDetails((prev) => ({ + ...prev, + [field]: value, + })); }, []); + useEffect(() => { + setProfileDetails( + data.reduce( + (acc, item) => { + acc[item.fieldId] = item.value; + return acc; + }, + {} as Record, + ), + ); + }, [data]); + if (isLoading) { return ; } @@ -214,7 +195,7 @@ export const ProgressiveProfilingForm = ({ {currentSection.fields.map((field) => ( handleInputChange(field.id, value)} componentMap={props.componentMap} {...field} diff --git a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx index 31449ff..6633c76 100644 --- a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx +++ b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx @@ -23,6 +23,11 @@ export const ProgressiveProfilingWrapper = () => { if (response.status === "OK") { setSections(response.sections); + + const isComplete = response.sections.every((section) => section.completed); + if (isComplete) { + await pluginConfig.onSuccess(data); + } } return response; From 993d70a0f69d34f1daba85eadd7f3edc0b07d26f Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 10:05:43 +0300 Subject: [PATCH 06/46] refactor and cleanup pr fixes --- .../src/plugin.ts | 244 ++---------------- .../src/progressive-profiling-service.ts | 229 ++++++++++++++++ .../progressive-profiling-nodejs/src/types.ts | 3 +- .../progressive-profiling-form.tsx | 36 ++- .../src/progressive-profiling-wrapper.tsx | 4 +- 5 files changed, 282 insertions(+), 234 deletions(-) create mode 100644 packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts diff --git a/packages/progressive-profiling-nodejs/src/plugin.ts b/packages/progressive-profiling-nodejs/src/plugin.ts index 5370cac..ef93b4a 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.ts @@ -1,225 +1,27 @@ import { SuperTokensPlugin } from "supertokens-node/types"; -import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; -import { BooleanClaim } from "supertokens-node/recipe/session/claims"; import { pluginUserMetadata, withRequestHandler } from "@shared/nodejs"; import { createPluginInitFunction } from "@shared/js"; import { ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; import { - FormSection, SuperTokensPluginProfileProgressiveProfilingConfig, - RegisterSection, UserMetadataConfig, + SuperTokensPluginProfileProgressiveProfilingNormalisedConfig, } from "./types"; import { HANDLE_BASE_PATH, PLUGIN_ID, METADATA_KEY, PLUGIN_SDK_VERSION } from "./constants"; -import { enableDebugLogs, logDebugMessage } from "./logger"; +import { enableDebugLogs } from "./logger"; +import { ProgressiveProfilingService } from "./progressive-profiling-service"; -const isSectionCompleted = (section: FormSection, data: ProfileFormData) => { - return section.fields.reduce((acc, field) => { - const value = data.find((d) => d.fieldId === field.id)?.value; - if (field.required && value === undefined) { - return acc && false; - } - - return acc && true; - }, true); -}; - -const areAllSectionsCompleted = (sections: FormSection[], profileConfig?: UserMetadataConfig) => { - return sections.reduce((acc, section) => { - return acc && (profileConfig?.sectionCompleted?.[section.id] ?? false); - }, true); -}; - -export const init = createPluginInitFunction( - () => { +export const init = createPluginInitFunction< + SuperTokensPlugin, + SuperTokensPluginProfileProgressiveProfilingConfig, + ProgressiveProfilingService, + SuperTokensPluginProfileProgressiveProfilingNormalisedConfig +>( + (_, implementation) => { const metadata = pluginUserMetadata<{ profileConfig?: UserMetadataConfig }>(METADATA_KEY); - const existingSections: (FormSection & { registererId: string })[] = []; - - const existingRegistererHandlers: Record[0], "set" | "get">> = {}; - - const registerSection: RegisterSection = ({ registererId, sections, set, get }) => { - const registrableSections = sections - .filter((section) => { - const existingSection = existingSections.find((s) => s.id === section.id); - if (existingSection) { - logDebugMessage( - `Profile plugin section with id "${section.id}" already registered by "${existingSection.registererId}". Skipping...`, - ); - return false; - } - - return true; - }) - .map((section) => ({ - ...section, - registererId, - })); - - existingSections.push(...registrableSections); - existingRegistererHandlers[registererId] = { set, get }; - }; - - const getSections = () => { - return existingSections; - }; - - const setSectionValues = async (session: SessionContainerInterface, data: ProfileFormData) => { - const userId = session.getUserId(); - if (!userId) { - throw new Error("User not found"); - } - - const sections = getSections(); - - const sectionIdToRegistererIdMap = sections.reduce( - (acc, section) => { - return { ...acc, [section.id]: section.registererId }; - }, - {} as Record, - ); - - const sectionsById = sections.reduce( - (acc, section) => { - return { ...acc, [section.id]: section }; - }, - {} as Record, - ); - - const dataBySectionId = data.reduce( - (acc, row) => { - return { ...acc, [row.sectionId]: [...(acc[row.sectionId] ?? []), row] }; - }, - {} as Record, - ); - - const dataByRegistererId = data.reduce( - (acc, row) => { - const registererId = sectionIdToRegistererIdMap[row.sectionId]; - if (registererId) { - return { ...acc, [registererId]: [...(acc[registererId] ?? []), row] }; - } - return acc; - }, - {} as Record, - ); - - const validationErrors: { id: string; error: string }[] = []; - for (const row of data) { - const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); - if (!field) { - validationErrors.push({ - id: row.fieldId, - error: `Field with id "${row.fieldId}" not found`, - }); - continue; - } - - if (field.required && row.value === undefined) { - validationErrors.push({ - id: field.id, - error: `Field value for field "${field.id}" is required`, - }); - continue; - } - - const validationError = await field.validation?.(row.value); - if (validationError) { - validationErrors.push({ id: field.id, error: validationError }); - } - } - - const updatedData: ProfileFormData = []; - for (const registererId of Object.keys(dataByRegistererId)) { - const sectionHandlers = existingRegistererHandlers[registererId]; - if (!sectionHandlers) { - continue; - } - const sectionData = dataByRegistererId[registererId]; - if (!sectionData) { - continue; - } - - await sectionHandlers.set(sectionData, session); - // get all the data from the storage, since data could be updated from other places or updated partially - const data = await sectionHandlers.get(session); - updatedData.push(...data); - } - - // do it like this to have a unique list of sections to update - const sectionsToUpdate = Object.keys(dataBySectionId) - .map((sectionId) => sections.find((s) => s.id === sectionId)) - .filter((s) => s !== undefined); - const sectionsCompleted: Record = {}; - for (const section of sectionsToUpdate) { - sectionsCompleted[section.id] = isSectionCompleted( - section, - updatedData.filter((d) => d.sectionId === section.id), - ); - } - - const userMetadata = await metadata.get(userId); - const newUserMetadata = { - ...userMetadata, - profileConfig: { - ...userMetadata?.profileConfig, - sectionCompleted: { - ...(userMetadata?.profileConfig?.sectionCompleted ?? {}), - ...sectionsCompleted, - }, - }, - }; - await metadata.set(userId, newUserMetadata); - - // refresh the claim to make sure the frontend has the latest value - // but only if all sections are completed - const allSectionsCompleted = areAllSectionsCompleted(getSections(), newUserMetadata?.profileConfig); - if (allSectionsCompleted) { - await session.fetchAndSetClaim(ProgressiveProfilingCompletedClaim); - } - - return { status: "OK" }; - }; - - const getSectionValues = async (session: SessionContainerInterface) => { - const userId = session.getUserId(); - if (!userId) { - throw new Error("User not found"); - } - - const sections = getSections(); - - const sectionsByRegistererId = sections.reduce( - (acc, section) => { - return { ...acc, [section.registererId]: section }; - }, - {} as Record, - ); - - const data: ProfileFormData = []; - for (const registererId of Object.keys(sectionsByRegistererId)) { - const sectionHandlers = existingRegistererHandlers[registererId]; - if (!sectionHandlers) { - continue; - } - - const sectionData = await sectionHandlers.get(session); - data.push(...sectionData); - } - - return data; - }; - - const ProgressiveProfilingCompletedClaim = new BooleanClaim({ - key: `${PLUGIN_ID}-completed`, - fetchValue: async (userId) => { - const userMetadata = await metadata.get(userId); - return areAllSectionsCompleted(getSections(), userMetadata?.profileConfig); - }, - }); - return { id: PLUGIN_ID, compatibleSDKVersions: PLUGIN_SDK_VERSION, @@ -240,7 +42,7 @@ export const init = createPluginInitFunction { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -257,7 +59,7 @@ export const init = createPluginInitFunction ({ + const sections = implementation.getSections().map((section) => ({ id: section.id, label: section.label, description: section.description, @@ -287,7 +89,7 @@ export const init = createPluginInitFunction { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -303,7 +105,7 @@ export const init = createPluginInitFunction { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -328,7 +130,7 @@ export const init = createPluginInitFunction { input.accessTokenPayload = { ...input.accessTokenPayload, - ...(await ProgressiveProfilingCompletedClaim.build( + ...(await ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.build( input.userId, input.recipeUserId, input.tenantId, @@ -367,11 +169,13 @@ export const init = createPluginInitFunction new ProgressiveProfilingService(config), ); diff --git a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts new file mode 100644 index 0000000..0d4e98d --- /dev/null +++ b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts @@ -0,0 +1,229 @@ +import { + RegisterSection, + FormSection, + SuperTokensPluginProfileProgressiveProfilingNormalisedConfig, + UserMetadataConfig, +} from "./types"; +import { logDebugMessage } from "./logger"; +import { ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; +import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; +import { BooleanClaim } from "supertokens-node/recipe/session/claims"; +import { PLUGIN_ID, METADATA_KEY } from "./constants"; +import { pluginUserMetadata } from "@shared/nodejs"; + +export class ProgressiveProfilingService { + protected existingSections: (FormSection & { registratorId: string })[] = []; + protected existingRegistratorHandlers: Record[0], "set" | "get">> = {}; + protected metadata = pluginUserMetadata<{ profileConfig?: UserMetadataConfig }>(METADATA_KEY); + + static ProgressiveProfilingCompletedClaim: BooleanClaim; + + static isSectionCompleted = function (section: FormSection, data: ProfileFormData) { + return section.fields.reduce((acc, field) => { + const value = data.find((d) => d.fieldId === field.id)?.value; + if (field.required && value === undefined) { + return acc && false; + } + + return acc && true; + }, true); + }; + + static areAllSectionsCompleted = function (sections: FormSection[], profileConfig?: UserMetadataConfig) { + return sections.reduce((acc, section) => { + return acc && (profileConfig?.sectionCompleted?.[section.id] ?? false); + }, true); + }; + + constructor(protected pluginConfig: SuperTokensPluginProfileProgressiveProfilingNormalisedConfig) { + ProgressiveProfilingService.ProgressiveProfilingCompletedClaim = new BooleanClaim({ + key: `${PLUGIN_ID}-completed`, + fetchValue: async (userId) => { + const userMetadata = await this.metadata.get(userId); + return ProgressiveProfilingService.areAllSectionsCompleted(this.getSections(), userMetadata?.profileConfig); + }, + }); + } + + registerSection: RegisterSection = function ( + this: ProgressiveProfilingService, + { registratorId, sections, set, get }, + ) { + const registrableSections = sections + .filter((section) => { + const existingSection = this.existingSections.find((s) => s.id === section.id); + if (existingSection) { + logDebugMessage( + `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...`, + ); + return false; + } + + return true; + }) + .map((section) => ({ + ...section, + registratorId, + })); + + this.existingSections.push(...registrableSections); + this.existingRegistratorHandlers[registratorId] = { set, get }; + }; + + getSections = function (this: ProgressiveProfilingService) { + return this.existingSections; + }; + + setSectionValues = async function ( + this: ProgressiveProfilingService, + session: SessionContainerInterface, + data: ProfileFormData, + ) { + const userId = session.getUserId(); + if (!userId) { + throw new Error("User not found"); + } + + const sections = this.getSections(); + + const sectionIdToRegistratorIdMap = sections.reduce( + (acc, section) => { + return { ...acc, [section.id]: section.registratorId }; + }, + {} as Record, + ); + + const sectionsById = sections.reduce( + (acc, section) => { + return { ...acc, [section.id]: section }; + }, + {} as Record, + ); + + const dataBySectionId = data.reduce( + (acc, row) => { + return { ...acc, [row.sectionId]: [...(acc[row.sectionId] ?? []), row] }; + }, + {} as Record, + ); + + const dataByRegistratorId = data.reduce( + (acc, row) => { + const registratorId = sectionIdToRegistratorIdMap[row.sectionId]; + if (registratorId) { + return { ...acc, [registratorId]: [...(acc[registratorId] ?? []), row] }; + } + return acc; + }, + {} as Record, + ); + + const validationErrors: { id: string; error: string }[] = []; + for (const row of data) { + const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); + if (!field) { + validationErrors.push({ + id: row.fieldId, + error: `Field with id "${row.fieldId}" not found`, + }); + continue; + } + + if (field.required && row.value === undefined) { + validationErrors.push({ + id: field.id, + error: `Field value for field "${field.id}" is required`, + }); + continue; + } + + const validationError = await field.validation?.(row.value); + if (validationError) { + validationErrors.push({ id: field.id, error: validationError }); + } + } + + const updatedData: ProfileFormData = []; + for (const registratorId of Object.keys(dataByRegistratorId)) { + const sectionHandlers = this.existingRegistratorHandlers[registratorId]; + if (!sectionHandlers) { + continue; + } + const sectionData = dataByRegistratorId[registratorId]; + if (!sectionData) { + continue; + } + + await sectionHandlers.set(sectionData, session); + // get all the data from the storage, since data could be updated from other places or updated partially + const data = await sectionHandlers.get(session); + updatedData.push(...data); + } + + // do it like this to have a unique list of sections to update + const sectionsToUpdate = Object.keys(dataBySectionId) + .map((sectionId) => sections.find((s) => s.id === sectionId)) + .filter((s) => s !== undefined); + const sectionsCompleted: Record = {}; + for (const section of sectionsToUpdate) { + sectionsCompleted[section.id] = ProgressiveProfilingService.isSectionCompleted( + section, + updatedData.filter((d) => d.sectionId === section.id), + ); + } + + const userMetadata = await this.metadata.get(userId); + const newUserMetadata = { + ...userMetadata, + profileConfig: { + ...userMetadata?.profileConfig, + sectionCompleted: { + ...(userMetadata?.profileConfig?.sectionCompleted ?? {}), + ...sectionsCompleted, + }, + }, + }; + await this.metadata.set(userId, newUserMetadata); + + // refresh the claim to make sure the frontend has the latest value + // but only if all sections are completed + const allSectionsCompleted = ProgressiveProfilingService.areAllSectionsCompleted( + this.getSections(), + newUserMetadata?.profileConfig, + ); + if (allSectionsCompleted) { + await session.fetchAndSetClaim(ProgressiveProfilingService.ProgressiveProfilingCompletedClaim); + } + + return { status: "OK" }; + }; + + getSectionValues = async function (this: ProgressiveProfilingService, session: SessionContainerInterface) { + const userId = session.getUserId(); + if (!userId) { + throw new Error("User not found"); + } + + const sections = this.getSections(); + + const sectionsByRegistratorId = sections.reduce( + (acc, section) => { + return { ...acc, [section.registratorId]: section }; + }, + {} as Record, + ); + + const data: ProfileFormData = []; + for (const registratorId of Object.keys(sectionsByRegistratorId)) { + const sectionHandlers = this.existingRegistratorHandlers[registratorId]; + if (!sectionHandlers) { + continue; + } + + const sectionData = await sectionHandlers.get(session); + data.push(...sectionData); + } + + return data; + }; +} diff --git a/packages/progressive-profiling-nodejs/src/types.ts b/packages/progressive-profiling-nodejs/src/types.ts index e0e596a..24c7f78 100644 --- a/packages/progressive-profiling-nodejs/src/types.ts +++ b/packages/progressive-profiling-nodejs/src/types.ts @@ -35,13 +35,14 @@ export type FormSection = { fields: FormField[]; }; export type SuperTokensPluginProfileProgressiveProfilingConfig = undefined; +export type SuperTokensPluginProfileProgressiveProfilingNormalisedConfig = undefined; export type UserMetadataConfig = { sectionCompleted: Record; }; export type RegisterSection = (section: { - registererId: string; + registratorId: string; sections: FormSection[]; set: (data: ProfileFormData, session: SessionContainerInterface | undefined) => Promise; get: (session: SessionContainerInterface | undefined) => Promise; diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index 1bcbc55..51f59e7 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -50,17 +50,22 @@ export const ProgressiveProfilingForm = ({ }, [props.sections]); const startingSectionIndex = useMemo(() => { - const index = sections.findIndex((section) => !section.completed); - return index === -1 ? 0 : index; + const notCompletedSectionIndexes = sections + .map((section, index) => (section.completed ? index : null)) + .filter((index) => index !== null); + + // if no sections are completed, or all of them are completed, return the first section + if (notCompletedSectionIndexes.length === 2 || notCompletedSectionIndexes.length === sections.length) { + return 0; + } + + // otherwise return the index of the first not completed section - it means the user hasn't completed all thsection + return notCompletedSectionIndexes[1] || 0; // return the first section index as a default }, [sections]); const [activeSectionIndex, setActiveSectionIndex] = useState(startingSectionIndex); const [profileDetails, setProfileDetails] = useState>({}); - const isComplete = useMemo(() => { - return sections.every((section) => section.completed); - }, [sections]); - const isLastSection = activeSectionIndex === sections.length - 1; const currentSection = useMemo(() => { @@ -103,8 +108,9 @@ export const ProgressiveProfilingForm = ({ ); const moveToNextSectionEnabled = useMemo(() => { + const isComplete = sections.slice(1, -1).every((section) => section.completed); return (isComplete && activeSectionIndex === sections.length - 1) || activeSectionIndex < sections.length - 1; - }, [isComplete, activeSectionIndex, sections]); + }, [activeSectionIndex, sections]); const moveToNextSectionLabel = useMemo(() => { if (activeSectionIndex === 0) { @@ -118,12 +124,15 @@ export const ProgressiveProfilingForm = ({ const isSectionEnabled = useCallback( (sectionIndex: number) => { - const section = sections[sectionIndex - 1]; - if (!section) { + if (sectionIndex === 0) { return true; - } // the first section is always enabled + } - return section.completed; + if (sectionIndex === sections.length - 1) { + return sections.slice(1, -1).every((section) => section.completed); + } + + return sections[sectionIndex]?.completed ?? false; }, [sections], ); @@ -133,6 +142,11 @@ export const ProgressiveProfilingForm = ({ return; } + if (currentSection.id === "profile-start") { + moveToNextSection(activeSectionIndex); + return; + } + const data: ProfileFormData = Object.entries(profileDetails).map(([key, value]) => { return { sectionId: currentSection.id, fieldId: key, value: value }; }); diff --git a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx index 6633c76..00d395c 100644 --- a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx +++ b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx @@ -22,12 +22,12 @@ export const ProgressiveProfilingWrapper = () => { } if (response.status === "OK") { - setSections(response.sections); - const isComplete = response.sections.every((section) => section.completed); if (isComplete) { await pluginConfig.onSuccess(data); } + + setSections(response.sections); } return response; From c645c3cc75f76862270304803d77ed8e197f2103 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 13:27:20 +0300 Subject: [PATCH 07/46] better error handling for pretty actions --- shared/ui/src/hooks/use-pretty-action.ts | 32 +++++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/shared/ui/src/hooks/use-pretty-action.ts b/shared/ui/src/hooks/use-pretty-action.ts index 3951a92..96ea360 100644 --- a/shared/ui/src/hooks/use-pretty-action.ts +++ b/shared/ui/src/hooks/use-pretty-action.ts @@ -1,6 +1,18 @@ import { useCallback } from "react"; import { useToast } from "../components/toast"; +const getErrorMessage = (e: any) => { + if (e.message) { + return e.message; + } + + if (e.name) { + return e.name; + } + + return "An error occurred"; +}; + export const usePrettyAction = any | Promise>( action: T, deps: any[] = [], @@ -34,10 +46,14 @@ export const usePrettyAction = any | Promise const handleError = useCallback( (e: any, ...args: Parameters) => { - const message = - typeof options.errorMessage === "function" - ? options.errorMessage(e, ...args) - : (options.errorMessage ?? "An error occurred"); + let message: string; + if (typeof options.errorMessage === "function") { + message = options.errorMessage(e, ...args); + } else if (typeof options.errorMessage === "string") { + message = options.errorMessage; + } else { + message = getErrorMessage(e); + } addToast({ message, @@ -68,6 +84,14 @@ export const usePrettyAction = any | Promise await options.onSuccess(); } + if (res.status && res.status !== "OK") { + handleError(res, ...args); + + if (options.onError) { + await options.onError(res); + } + } + return res as ReturnType extends Promise ? Awaited> : ReturnType; } catch (e) { handleError(e, ...args); From 01707c97c9ff78154b4194acdd06c59c39f3d044 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 13:28:09 +0300 Subject: [PATCH 08/46] add separate querier errors --- shared/react/src/querier.ts | 45 +++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/shared/react/src/querier.ts b/shared/react/src/querier.ts index dee9db2..ca2f83c 100644 --- a/shared/react/src/querier.ts +++ b/shared/react/src/querier.ts @@ -1,3 +1,12 @@ +export class QuerierError extends Error { + constructor( + message: string, + public payload: any, + ) { + super(message); + } +} + const post = (basePath: string) => async ( @@ -14,38 +23,24 @@ const post = credentials.credentials = "include"; } - let response; - try { - response = await fetch(url, { - ...credentials, - method: "POST", - headers: { - "Content-type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(body), - }); - } catch (error) { - const newError = new Error(`Fetch failed: ${error}`); - let payload = error; - try { - payload = JSON.parse(error as string); - } catch (e) {} - // @ts-ignore - newError.payload = payload; - throw newError; - } + const response = await fetch(url, { + ...credentials, + method: "POST", + headers: { + "Content-type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); if (!response.ok) { const error = await response.text(); - const newError = new Error(`Fetch failed: ${error}`); let payload = error; try { payload = JSON.parse(error); } catch (e) {} - // @ts-ignore - newError.payload = payload; - throw newError; + + throw new QuerierError(`Fetch failed: ${error}`, payload); } return response.json() as Promise; From 8e0cc2395ae76490cfa3d10e131bf9dacee5e930 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 13:28:20 +0300 Subject: [PATCH 09/46] type fixes --- packages/progressive-profiling-shared/src/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/progressive-profiling-shared/src/types.ts b/packages/progressive-profiling-shared/src/types.ts index dcab7f0..f808873 100644 --- a/packages/progressive-profiling-shared/src/types.ts +++ b/packages/progressive-profiling-shared/src/types.ts @@ -15,7 +15,8 @@ export type FormField = { | "multiselect" | "password" | "url" - | "image-url"; + | "image-url" + | "toggle"; required: boolean; defaultValue?: FormFieldValue; placeholder?: string; From 99319c14a6549db869b066a6bf47ba13efa77b27 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 13:28:33 +0300 Subject: [PATCH 10/46] added utility functions --- shared/js/src/groupBy.ts | 10 ++++++++++ shared/js/src/index.ts | 2 ++ shared/js/src/indexBy.ts | 10 ++++++++++ 3 files changed, 22 insertions(+) create mode 100644 shared/js/src/groupBy.ts create mode 100644 shared/js/src/indexBy.ts diff --git a/shared/js/src/groupBy.ts b/shared/js/src/groupBy.ts new file mode 100644 index 0000000..e5af787 --- /dev/null +++ b/shared/js/src/groupBy.ts @@ -0,0 +1,10 @@ +export const groupBy = (array: T[], key: keyof T | ((item: T) => string)): Record => { + return array.reduce( + (acc, item) => { + const _key = typeof key === "function" ? key(item) : (item[key] as string); + acc[_key] = [...(acc[_key] ?? []), item]; + return acc; + }, + {} as Record, + ); +}; diff --git a/shared/js/src/index.ts b/shared/js/src/index.ts index bffc096..525f192 100644 --- a/shared/js/src/index.ts +++ b/shared/js/src/index.ts @@ -1 +1,3 @@ export * from "./createPluginInit"; +export * from "./indexBy"; +export * from "./groupBy"; diff --git a/shared/js/src/indexBy.ts b/shared/js/src/indexBy.ts new file mode 100644 index 0000000..fbe66b8 --- /dev/null +++ b/shared/js/src/indexBy.ts @@ -0,0 +1,10 @@ +export const indexBy = (array: T[], index: keyof T | ((item: T) => string)): Record => { + return array.reduce( + (acc, item) => { + const key = typeof index === "function" ? index(item) : (item[index] as string); + acc[key] = item; + return acc; + }, + {} as Record, + ); +}; From 82a97289052718b5586e1dcf44fff497da421b80 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 13:32:18 +0300 Subject: [PATCH 11/46] improve error handling and messaging on frontend --- .../src/plugin.ts | 10 +- .../src/progressive-profiling-service.ts | 121 ++++++++---------- .../progressive-profiling-nodejs/src/types.ts | 40 +----- .../progressive-profiling-react/src/api.ts | 58 +++++++-- .../progressive-profiling-form.tsx | 50 ++++++-- 5 files changed, 151 insertions(+), 128 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/plugin.ts b/packages/progressive-profiling-nodejs/src/plugin.ts index ef93b4a..4fcb45a 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.ts @@ -42,7 +42,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key ); }, }, @@ -89,7 +89,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key ); }, }, @@ -116,7 +116,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key ); }, }, @@ -157,7 +157,7 @@ export const init = createPluginInitFunction< input.recipeUserId, input.tenantId, input.accessTokenPayload, - input.userContext, + input.userContext )), }; @@ -177,5 +177,5 @@ export const init = createPluginInitFunction< }; }, - (config) => new ProgressiveProfilingService(config), + (config) => new ProgressiveProfilingService(config) ); diff --git a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts index 0d4e98d..9e74f0e 100644 --- a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts +++ b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts @@ -5,7 +5,7 @@ import { UserMetadataConfig, } from "./types"; import { logDebugMessage } from "./logger"; -import { ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; +import { FormField, FormFieldValue, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; import { BooleanClaim } from "supertokens-node/recipe/session/claims"; import { PLUGIN_ID, METADATA_KEY } from "./constants"; @@ -18,21 +18,8 @@ export class ProgressiveProfilingService { static ProgressiveProfilingCompletedClaim: BooleanClaim; - static isSectionCompleted = function (section: FormSection, data: ProfileFormData) { - return section.fields.reduce((acc, field) => { - const value = data.find((d) => d.fieldId === field.id)?.value; - if (field.required && value === undefined) { - return acc && false; - } - - return acc && true; - }, true); - }; - - static areAllSectionsCompleted = function (sections: FormSection[], profileConfig?: UserMetadataConfig) { - return sections.reduce((acc, section) => { - return acc && (profileConfig?.sectionCompleted?.[section.id] ?? false); - }, true); + static areAllSectionsCompleted = (sections: FormSection[], profileConfig?: UserMetadataConfig) => { + return sections.every((section) => profileConfig?.sectionCompleted?.[section.id] ?? false); }; constructor(protected pluginConfig: SuperTokensPluginProfileProgressiveProfilingNormalisedConfig) { @@ -47,14 +34,14 @@ export class ProgressiveProfilingService { registerSection: RegisterSection = function ( this: ProgressiveProfilingService, - { registratorId, sections, set, get }, + { registratorId, sections, set, get } ) { const registrableSections = sections .filter((section) => { const existingSection = this.existingSections.find((s) => s.id === section.id); if (existingSection) { logDebugMessage( - `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...`, + `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...` ); return false; } @@ -77,7 +64,7 @@ export class ProgressiveProfilingService { setSectionValues = async function ( this: ProgressiveProfilingService, session: SessionContainerInterface, - data: ProfileFormData, + data: ProfileFormData ) { const userId = session.getUserId(); if (!userId) { @@ -86,37 +73,25 @@ export class ProgressiveProfilingService { const sections = this.getSections(); - const sectionIdToRegistratorIdMap = sections.reduce( - (acc, section) => { - return { ...acc, [section.id]: section.registratorId }; - }, - {} as Record, - ); + const sectionIdToRegistratorIdMap = sections.reduce((acc, section) => { + return { ...acc, [section.id]: section.registratorId }; + }, {} as Record); - const sectionsById = sections.reduce( - (acc, section) => { - return { ...acc, [section.id]: section }; - }, - {} as Record, - ); + const sectionsById = sections.reduce((acc, section) => { + return { ...acc, [section.id]: section }; + }, {} as Record); - const dataBySectionId = data.reduce( - (acc, row) => { - return { ...acc, [row.sectionId]: [...(acc[row.sectionId] ?? []), row] }; - }, - {} as Record, - ); + const dataBySectionId = data.reduce((acc, row) => { + return { ...acc, [row.sectionId]: [...(acc[row.sectionId] ?? []), row] }; + }, {} as Record); - const dataByRegistratorId = data.reduce( - (acc, row) => { - const registratorId = sectionIdToRegistratorIdMap[row.sectionId]; - if (registratorId) { - return { ...acc, [registratorId]: [...(acc[registratorId] ?? []), row] }; - } - return acc; - }, - {} as Record, - ); + const dataByRegistratorId = data.reduce((acc, row) => { + const registratorId = sectionIdToRegistratorIdMap[row.sectionId]; + if (registratorId) { + return { ...acc, [registratorId]: [...(acc[registratorId] ?? []), row] }; + } + return acc; + }, {} as Record); const validationErrors: { id: string; error: string }[] = []; for (const row of data) { @@ -129,20 +104,17 @@ export class ProgressiveProfilingService { continue; } - if (field.required && row.value === undefined) { - validationErrors.push({ - id: field.id, - error: `Field value for field "${field.id}" is required`, - }); - continue; - } + const fieldError = this.validateField(field, row.value); - const validationError = await field.validation?.(row.value); - if (validationError) { - validationErrors.push({ id: field.id, error: validationError }); + if (fieldError) { + validationErrors.push({ id: field.id, error: fieldError }); } } + if (validationErrors.length > 0) { + return { status: "INVALID_FIELDS", errors: validationErrors }; + } + const updatedData: ProfileFormData = []; for (const registratorId of Object.keys(dataByRegistratorId)) { const sectionHandlers = this.existingRegistratorHandlers[registratorId]; @@ -166,9 +138,9 @@ export class ProgressiveProfilingService { .filter((s) => s !== undefined); const sectionsCompleted: Record = {}; for (const section of sectionsToUpdate) { - sectionsCompleted[section.id] = ProgressiveProfilingService.isSectionCompleted( + sectionsCompleted[section.id] = await this.isSectionCompleted( section, - updatedData.filter((d) => d.sectionId === section.id), + updatedData.filter((d) => d.sectionId === section.id) ); } @@ -189,7 +161,7 @@ export class ProgressiveProfilingService { // but only if all sections are completed const allSectionsCompleted = ProgressiveProfilingService.areAllSectionsCompleted( this.getSections(), - newUserMetadata?.profileConfig, + newUserMetadata?.profileConfig ); if (allSectionsCompleted) { await session.fetchAndSetClaim(ProgressiveProfilingService.ProgressiveProfilingCompletedClaim); @@ -206,12 +178,9 @@ export class ProgressiveProfilingService { const sections = this.getSections(); - const sectionsByRegistratorId = sections.reduce( - (acc, section) => { - return { ...acc, [section.registratorId]: section }; - }, - {} as Record, - ); + const sectionsByRegistratorId = sections.reduce((acc, section) => { + return { ...acc, [section.registratorId]: section }; + }, {} as Record); const data: ProfileFormData = []; for (const registratorId of Object.keys(sectionsByRegistratorId)) { @@ -226,4 +195,24 @@ export class ProgressiveProfilingService { return data; }; + + validateField = function ( + this: ProgressiveProfilingService, + field: FormField, + value: FormFieldValue + ): string | undefined { + if (field.required && (value === undefined || (typeof value === "string" && value.trim() === ""))) { + return `The "${field.label}" field is required`; + } + + return undefined; + }; + + isSectionCompleted = async function (this: ProgressiveProfilingService, section: FormSection, data: ProfileFormData) { + const valuesByFieldId = data.reduce((acc, row) => { + return { ...acc, [row.fieldId]: row.value }; + }, {} as Record); + + return section.fields.every((field) => this.validateField(field, valuesByFieldId[field.id]) === undefined); + }; } diff --git a/packages/progressive-profiling-nodejs/src/types.ts b/packages/progressive-profiling-nodejs/src/types.ts index 24c7f78..5e58329 100644 --- a/packages/progressive-profiling-nodejs/src/types.ts +++ b/packages/progressive-profiling-nodejs/src/types.ts @@ -1,46 +1,16 @@ import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; -import { ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; +import { ProfileFormData, FormSection as SharedFormSection } from "@supertokens-plugins/progressive-profiling-shared"; -export type FormFieldValue = string | number | boolean | null | undefined | string[]; - -export type FormField = { - id: string; - label: string; - type: - | "string" - | "text" - | "number" - | "boolean" - | "email" - | "phone" - | "date" - | "select" - | "multiselect" - | "password" - | "url" - | "image-url" - | "toggle"; - required: boolean; - defaultValue?: FormFieldValue; - placeholder?: string; - description?: string; - validation?: (value: FormFieldValue) => Promise; - options?: { value: FormFieldValue; label: string }[]; -}; - -export type FormSection = { - id: string; - label: string; - description?: string; - fields: FormField[]; -}; export type SuperTokensPluginProfileProgressiveProfilingConfig = undefined; -export type SuperTokensPluginProfileProgressiveProfilingNormalisedConfig = undefined; +export type SuperTokensPluginProfileProgressiveProfilingNormalisedConfig = + Required; export type UserMetadataConfig = { sectionCompleted: Record; }; +export type FormSection = Omit; + export type RegisterSection = (section: { registratorId: string; sections: FormSection[]; diff --git a/packages/progressive-profiling-react/src/api.ts b/packages/progressive-profiling-react/src/api.ts index ec7a106..c1405d1 100644 --- a/packages/progressive-profiling-react/src/api.ts +++ b/packages/progressive-profiling-react/src/api.ts @@ -1,27 +1,57 @@ -import { getQuerier } from "@shared/react"; +import { getQuerier, QuerierError } from "@shared/react"; import { FormSection, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; export const getApi = (querier: ReturnType) => { const getSections = async () => { - return await querier.get<{ status: "OK"; sections: FormSection[] } | { status: "ERROR"; message: string }>( - "/sections", - { - withSession: true, - }, - ); + try { + return await querier.get<{ status: "OK"; sections: FormSection[] } | { status: "ERROR"; message: string }>( + "/sections", + { + withSession: true, + }, + ); + } catch (error) { + if (error instanceof QuerierError) { + return error.payload; + } + + throw error; + } }; const getProfile = async () => { - return await querier.get<{ status: "OK"; data: ProfileFormData } | { status: "ERROR"; message: string }>( - "/profile", - { withSession: true }, - ); + try { + return await querier.get<{ status: "OK"; data: ProfileFormData } | { status: "ERROR"; message: string }>( + "/profile", + { + withSession: true, + }, + ); + } catch (error) { + if (error instanceof QuerierError) { + return error.payload; + } + + throw error; + } }; const updateProfile = async (payload: { data: ProfileFormData }) => { - return await querier.post<{ status: "OK" } | { status: "ERROR"; message: string }>("/profile", payload, { - withSession: true, - }); + try { + return await querier.post< + | { status: "OK" } + | { status: "ERROR"; message: string } + | { status: "INVALID_FIELDS"; errors: { id: string; error: string }[] } + >("/profile", payload, { + withSession: true, + }); + } catch (error) { + if (error instanceof QuerierError) { + return error.payload; + } + + throw error; + } }; return { diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index 51f59e7..cd24348 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -1,4 +1,5 @@ -import { Button, FormInput, FormFieldValue, Card } from "@shared/ui"; +import { groupBy } from "@shared/js"; +import { Button, FormInput, FormFieldValue, Card, usePrettyAction } from "@shared/ui"; import { FormSection, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; import classNames from "classnames/bind"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -13,7 +14,13 @@ const cx = classNames.bind(styles); interface ProgressiveProfilingFormProps { sections: FormSection[]; data: ProfileFormData; - onSubmit: (data: ProfileFormData) => Promise<{ status: "OK" } | { status: "ERROR"; message: string }>; + onSubmit: ( + data: ProfileFormData, + ) => Promise< + | { status: "OK" } + | { status: "ERROR"; message: string } + | { status: "INVALID_FIELDS"; errors: { id: string; error: string }[] } + >; onSuccess: (data: ProfileFormData) => Promise; isLoading: boolean; fetchFormData: () => Promise<{ status: "OK"; data: ProfileFormData } | { status: "ERROR"; message: string }>; @@ -28,6 +35,18 @@ export const ProgressiveProfilingForm = ({ ...props }: ProgressiveProfilingFormProps) => { const { t } = usePluginContext(); + const [fieldErrors, setFieldErrors] = useState>({}); + const onSubmitAction = usePrettyAction( + async (data: ProfileFormData) => { + const result = await onSubmit(data); + if (result.status === "INVALID_FIELDS") { + return { ...result, message: "Some fields are invalid" }; + } + + return result; + }, + [onSubmit], + ); const sections = useMemo(() => { return [ @@ -128,16 +147,22 @@ export const ProgressiveProfilingForm = ({ return true; } + if (sectionIndex === activeSectionIndex) { + return true; + } + if (sectionIndex === sections.length - 1) { return sections.slice(1, -1).every((section) => section.completed); } return sections[sectionIndex]?.completed ?? false; }, - [sections], + [sections, activeSectionIndex], ); const handleSubmit = useCallback(async () => { + setFieldErrors({}); + if (!currentSection) { return; } @@ -151,15 +176,23 @@ export const ProgressiveProfilingForm = ({ return { sectionId: currentSection.id, fieldId: key, value: value }; }); - const result = await onSubmit(data); - if (result.status === "ERROR") { - console.error(result); - } else if (isLastSection && result.status === "OK") { + if (currentSection.id === "profile-end") { await onSuccess(data); + return; + } + + // only send the current section fields + const sectionData = data.filter((row) => { + return currentSection.fields.find((field) => field.id === row.fieldId); + }); + + const result = await onSubmitAction(sectionData); + if (result.status === "INVALID_FIELDS") { + setFieldErrors(groupBy(result.errors, "id")); } else { moveToNextSection(activeSectionIndex); } - }, [currentSection, isLastSection, onSubmit, onSuccess, moveToNextSection, activeSectionIndex, profileDetails]); + }, [currentSection, isLastSection, onSubmitAction, onSuccess, moveToNextSection, activeSectionIndex, profileDetails]); const handleInputChange = useCallback((field: string, value: any) => { setProfileDetails((prev) => ({ @@ -212,6 +245,7 @@ export const ProgressiveProfilingForm = ({ value={profileDetails[field.id]} onChange={(value) => handleInputChange(field.id, value)} componentMap={props.componentMap} + error={fieldErrors[field.id]?.map((error) => error.error).join("\n")} {...field} /> ))} From 86d2c1065999688f3d37ff1f183189327620b58a Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 13:49:22 +0300 Subject: [PATCH 12/46] cleanup --- .../src/progressive-profiling-service.ts | 45 ++++++------------- .../progressive-profiling-form.tsx | 13 ++---- shared/js/src/groupBy.ts | 6 ++- shared/js/src/index.ts | 1 + shared/js/src/indexBy.ts | 6 ++- shared/js/src/mapBy.ts | 18 ++++++++ 6 files changed, 45 insertions(+), 44 deletions(-) create mode 100644 shared/js/src/mapBy.ts diff --git a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts index 9e74f0e..1dec358 100644 --- a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts +++ b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts @@ -10,6 +10,7 @@ import { SessionContainerInterface } from "supertokens-node/recipe/session/types import { BooleanClaim } from "supertokens-node/recipe/session/claims"; import { PLUGIN_ID, METADATA_KEY } from "./constants"; import { pluginUserMetadata } from "@shared/nodejs"; +import { groupBy, indexBy, mapBy } from "@shared/js"; export class ProgressiveProfilingService { protected existingSections: (FormSection & { registratorId: string })[] = []; @@ -34,14 +35,14 @@ export class ProgressiveProfilingService { registerSection: RegisterSection = function ( this: ProgressiveProfilingService, - { registratorId, sections, set, get } + { registratorId, sections, set, get }, ) { const registrableSections = sections .filter((section) => { const existingSection = this.existingSections.find((s) => s.id === section.id); if (existingSection) { logDebugMessage( - `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...` + `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...`, ); return false; } @@ -64,7 +65,7 @@ export class ProgressiveProfilingService { setSectionValues = async function ( this: ProgressiveProfilingService, session: SessionContainerInterface, - data: ProfileFormData + data: ProfileFormData, ) { const userId = session.getUserId(); if (!userId) { @@ -73,25 +74,10 @@ export class ProgressiveProfilingService { const sections = this.getSections(); - const sectionIdToRegistratorIdMap = sections.reduce((acc, section) => { - return { ...acc, [section.id]: section.registratorId }; - }, {} as Record); - - const sectionsById = sections.reduce((acc, section) => { - return { ...acc, [section.id]: section }; - }, {} as Record); - - const dataBySectionId = data.reduce((acc, row) => { - return { ...acc, [row.sectionId]: [...(acc[row.sectionId] ?? []), row] }; - }, {} as Record); - - const dataByRegistratorId = data.reduce((acc, row) => { - const registratorId = sectionIdToRegistratorIdMap[row.sectionId]; - if (registratorId) { - return { ...acc, [registratorId]: [...(acc[registratorId] ?? []), row] }; - } - return acc; - }, {} as Record); + const sectionsById = indexBy(sections, "id"); + const dataBySectionId = groupBy(data, "sectionId"); + const dataByRegistratorId = groupBy(data, (row) => sectionIdToRegistratorIdMap[row.sectionId]); + const sectionIdToRegistratorIdMap = mapBy(sections, "id", (section) => section.registratorId); const validationErrors: { id: string; error: string }[] = []; for (const row of data) { @@ -140,7 +126,7 @@ export class ProgressiveProfilingService { for (const section of sectionsToUpdate) { sectionsCompleted[section.id] = await this.isSectionCompleted( section, - updatedData.filter((d) => d.sectionId === section.id) + updatedData.filter((d) => d.sectionId === section.id), ); } @@ -161,7 +147,7 @@ export class ProgressiveProfilingService { // but only if all sections are completed const allSectionsCompleted = ProgressiveProfilingService.areAllSectionsCompleted( this.getSections(), - newUserMetadata?.profileConfig + newUserMetadata?.profileConfig, ); if (allSectionsCompleted) { await session.fetchAndSetClaim(ProgressiveProfilingService.ProgressiveProfilingCompletedClaim); @@ -178,9 +164,7 @@ export class ProgressiveProfilingService { const sections = this.getSections(); - const sectionsByRegistratorId = sections.reduce((acc, section) => { - return { ...acc, [section.registratorId]: section }; - }, {} as Record); + const sectionsByRegistratorId = indexBy(sections, "registratorId"); const data: ProfileFormData = []; for (const registratorId of Object.keys(sectionsByRegistratorId)) { @@ -199,7 +183,7 @@ export class ProgressiveProfilingService { validateField = function ( this: ProgressiveProfilingService, field: FormField, - value: FormFieldValue + value: FormFieldValue, ): string | undefined { if (field.required && (value === undefined || (typeof value === "string" && value.trim() === ""))) { return `The "${field.label}" field is required`; @@ -209,10 +193,7 @@ export class ProgressiveProfilingService { }; isSectionCompleted = async function (this: ProgressiveProfilingService, section: FormSection, data: ProfileFormData) { - const valuesByFieldId = data.reduce((acc, row) => { - return { ...acc, [row.fieldId]: row.value }; - }, {} as Record); - + const valuesByFieldId = mapBy(data, "fieldId", (row) => row.value); return section.fields.every((field) => this.validateField(field, valuesByFieldId[field.id]) === undefined); }; } diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index cd24348..704a9d0 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -36,6 +36,7 @@ export const ProgressiveProfilingForm = ({ }: ProgressiveProfilingFormProps) => { const { t } = usePluginContext(); const [fieldErrors, setFieldErrors] = useState>({}); + const onSubmitAction = usePrettyAction( async (data: ProfileFormData) => { const result = await onSubmit(data); @@ -85,15 +86,7 @@ export const ProgressiveProfilingForm = ({ const [activeSectionIndex, setActiveSectionIndex] = useState(startingSectionIndex); const [profileDetails, setProfileDetails] = useState>({}); - const isLastSection = activeSectionIndex === sections.length - 1; - - const currentSection = useMemo(() => { - if (activeSectionIndex === -1) { - return null; - } - - return sections[activeSectionIndex]; - }, [sections, activeSectionIndex]); + const currentSection = sections[activeSectionIndex]; const moveToNextSection = useCallback( (currentSectionIndex: number) => { @@ -192,7 +185,7 @@ export const ProgressiveProfilingForm = ({ } else { moveToNextSection(activeSectionIndex); } - }, [currentSection, isLastSection, onSubmitAction, onSuccess, moveToNextSection, activeSectionIndex, profileDetails]); + }, [onSubmitAction, onSuccess, moveToNextSection, activeSectionIndex, profileDetails]); const handleInputChange = useCallback((field: string, value: any) => { setProfileDetails((prev) => ({ diff --git a/shared/js/src/groupBy.ts b/shared/js/src/groupBy.ts index e5af787..aa52220 100644 --- a/shared/js/src/groupBy.ts +++ b/shared/js/src/groupBy.ts @@ -1,7 +1,11 @@ -export const groupBy = (array: T[], key: keyof T | ((item: T) => string)): Record => { +export const groupBy = (array: T[], key: keyof T | ((item: T) => string | undefined)): Record => { return array.reduce( (acc, item) => { const _key = typeof key === "function" ? key(item) : (item[key] as string); + if (!_key) { + return acc; + } + acc[_key] = [...(acc[_key] ?? []), item]; return acc; }, diff --git a/shared/js/src/index.ts b/shared/js/src/index.ts index 525f192..a3d12ae 100644 --- a/shared/js/src/index.ts +++ b/shared/js/src/index.ts @@ -1,3 +1,4 @@ export * from "./createPluginInit"; export * from "./indexBy"; export * from "./groupBy"; +export * from "./mapBy"; diff --git a/shared/js/src/indexBy.ts b/shared/js/src/indexBy.ts index fbe66b8..0ddb38a 100644 --- a/shared/js/src/indexBy.ts +++ b/shared/js/src/indexBy.ts @@ -1,7 +1,11 @@ -export const indexBy = (array: T[], index: keyof T | ((item: T) => string)): Record => { +export const indexBy = (array: T[], index: keyof T | ((item: T) => string | undefined)): Record => { return array.reduce( (acc, item) => { const key = typeof index === "function" ? index(item) : (item[index] as string); + if (!key) { + return acc; + } + acc[key] = item; return acc; }, diff --git a/shared/js/src/mapBy.ts b/shared/js/src/mapBy.ts new file mode 100644 index 0000000..aa5eee7 --- /dev/null +++ b/shared/js/src/mapBy.ts @@ -0,0 +1,18 @@ +export const mapBy = ( + array: T[], + key: keyof T | ((item: T) => string | undefined), + mapper: (item: T) => R, +): Record => { + return array.reduce( + (acc, item) => { + const _key = typeof key === "function" ? key(item) : (item[key] as string); + if (!_key) { + return acc; + } + + acc[_key] = mapper(item); + return acc; + }, + {} as Record, + ); +}; From 98cb44738518f81a160bad198166f784f0a76ffe Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 14:33:42 +0300 Subject: [PATCH 13/46] cleanup and fix section saving --- .../src/progressive-profiling-service.ts | 14 +-- .../progressive-profiling-form.tsx | 104 +++++++----------- 2 files changed, 48 insertions(+), 70 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts index 1dec358..9b39353 100644 --- a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts +++ b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts @@ -35,14 +35,14 @@ export class ProgressiveProfilingService { registerSection: RegisterSection = function ( this: ProgressiveProfilingService, - { registratorId, sections, set, get }, + { registratorId, sections, set, get } ) { const registrableSections = sections .filter((section) => { const existingSection = this.existingSections.find((s) => s.id === section.id); if (existingSection) { logDebugMessage( - `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...`, + `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...` ); return false; } @@ -65,7 +65,7 @@ export class ProgressiveProfilingService { setSectionValues = async function ( this: ProgressiveProfilingService, session: SessionContainerInterface, - data: ProfileFormData, + data: ProfileFormData ) { const userId = session.getUserId(); if (!userId) { @@ -76,8 +76,8 @@ export class ProgressiveProfilingService { const sectionsById = indexBy(sections, "id"); const dataBySectionId = groupBy(data, "sectionId"); - const dataByRegistratorId = groupBy(data, (row) => sectionIdToRegistratorIdMap[row.sectionId]); const sectionIdToRegistratorIdMap = mapBy(sections, "id", (section) => section.registratorId); + const dataByRegistratorId = groupBy(data, (row) => sectionIdToRegistratorIdMap[row.sectionId]); const validationErrors: { id: string; error: string }[] = []; for (const row of data) { @@ -126,7 +126,7 @@ export class ProgressiveProfilingService { for (const section of sectionsToUpdate) { sectionsCompleted[section.id] = await this.isSectionCompleted( section, - updatedData.filter((d) => d.sectionId === section.id), + updatedData.filter((d) => d.sectionId === section.id) ); } @@ -147,7 +147,7 @@ export class ProgressiveProfilingService { // but only if all sections are completed const allSectionsCompleted = ProgressiveProfilingService.areAllSectionsCompleted( this.getSections(), - newUserMetadata?.profileConfig, + newUserMetadata?.profileConfig ); if (allSectionsCompleted) { await session.fetchAndSetClaim(ProgressiveProfilingService.ProgressiveProfilingCompletedClaim); @@ -183,7 +183,7 @@ export class ProgressiveProfilingService { validateField = function ( this: ProgressiveProfilingService, field: FormField, - value: FormFieldValue, + value: FormFieldValue ): string | undefined { if (field.required && (value === undefined || (typeof value === "string" && value.trim() === ""))) { return `The "${field.label}" field is required`; diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index 704a9d0..b8d4a81 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -1,5 +1,5 @@ import { groupBy } from "@shared/js"; -import { Button, FormInput, FormFieldValue, Card, usePrettyAction } from "@shared/ui"; +import { Button, FormInput, FormFieldValue, Card, usePrettyAction, useToast } from "@shared/ui"; import { FormSection, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; import classNames from "classnames/bind"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -32,33 +32,22 @@ export const ProgressiveProfilingForm = ({ onSubmit, onSuccess, isLoading, + sections: formSections, ...props }: ProgressiveProfilingFormProps) => { const { t } = usePluginContext(); const [fieldErrors, setFieldErrors] = useState>({}); - const onSubmitAction = usePrettyAction( - async (data: ProfileFormData) => { - const result = await onSubmit(data); - if (result.status === "INVALID_FIELDS") { - return { ...result, message: "Some fields are invalid" }; - } - - return result; - }, - [onSubmit], - ); - const sections = useMemo(() => { return [ { id: "profile-start", label: t("PL_PP_SECTION_PROFILE_START_LABEL"), - description: t("PL_PP_SECTION_PROFILE_START_DESCRIPTION", { steps: (props.sections.length + 2).toString() }), + description: t("PL_PP_SECTION_PROFILE_START_DESCRIPTION", { steps: (formSections.length + 2).toString() }), completed: false, fields: [], }, - ...props.sections, + ...formSections, { id: "profile-end", label: t("PL_PP_SECTION_PROFILE_END_LABEL"), @@ -67,7 +56,7 @@ export const ProgressiveProfilingForm = ({ fields: [], }, ]; - }, [props.sections]); + }, [formSections]); const startingSectionIndex = useMemo(() => { const notCompletedSectionIndexes = sections @@ -87,20 +76,8 @@ export const ProgressiveProfilingForm = ({ const [profileDetails, setProfileDetails] = useState>({}); const currentSection = sections[activeSectionIndex]; - - const moveToNextSection = useCallback( - (currentSectionIndex: number) => { - if (currentSectionIndex === -1) { - return; - } - if (currentSectionIndex === sections.length - 1) { - return; - } - - setActiveSectionIndex(currentSectionIndex + 1); - }, - [sections], - ); + const isLastSection = activeSectionIndex === sections.length - 1; + const isFirstSection = activeSectionIndex === 0; const moveToSection = useCallback( (sectionIndex: number) => { @@ -110,50 +87,43 @@ export const ProgressiveProfilingForm = ({ if (sectionIndex >= sections.length) { return; } - if (!isSectionEnabled(sectionIndex)) { - return; - } setActiveSectionIndex(sectionIndex); }, [sections], ); - const moveToNextSectionEnabled = useMemo(() => { - const isComplete = sections.slice(1, -1).every((section) => section.completed); - return (isComplete && activeSectionIndex === sections.length - 1) || activeSectionIndex < sections.length - 1; - }, [activeSectionIndex, sections]); - - const moveToNextSectionLabel = useMemo(() => { - if (activeSectionIndex === 0) { - return t("PL_PP_SECTION_NEXT_BUTTON"); - } - if (activeSectionIndex === sections.length - 1) { - return t("PL_PP_SECTION_COMPLETE_BUTTON"); - } - return t("PL_PP_SECTION_SAVE_AND_NEXT_BUTTON"); - }, [sections, activeSectionIndex]); + const moveToNextSection = useCallback( + (currentSectionIndex: number) => { + moveToSection(currentSectionIndex + 1); + }, + [moveToSection], + ); const isSectionEnabled = useCallback( (sectionIndex: number) => { + // first section is always enabled if (sectionIndex === 0) { return true; } + // active section is always enabled if (sectionIndex === activeSectionIndex) { return true; } + // last section is enabled if all form sections are completed if (sectionIndex === sections.length - 1) { - return sections.slice(1, -1).every((section) => section.completed); + return formSections.every((section) => section.completed); } + // other sections are enabled if they are completed return sections[sectionIndex]?.completed ?? false; }, - [sections, activeSectionIndex], + [sections, activeSectionIndex, formSections], ); - const handleSubmit = useCallback(async () => { + const handleSubmit = usePrettyAction(async () => { setFieldErrors({}); if (!currentSection) { @@ -165,27 +135,33 @@ export const ProgressiveProfilingForm = ({ return; } - const data: ProfileFormData = Object.entries(profileDetails).map(([key, value]) => { - return { sectionId: currentSection.id, fieldId: key, value: value }; - }); - if (currentSection.id === "profile-end") { - await onSuccess(data); - return; + const isComplete = formSections.every((section) => section.completed); + if (isComplete) { + const data: ProfileFormData = Object.entries(profileDetails).map(([key, value]) => { + return { sectionId: currentSection.id, fieldId: key, value: value }; + }); + await onSuccess(data); + } else { + throw new Error("All sections must be completed to submit the form"); + } } // only send the current section fields - const sectionData = data.filter((row) => { - return currentSection.fields.find((field) => field.id === row.fieldId); + const sectionData = currentSection.fields.map((field) => { + return { sectionId: currentSection.id, fieldId: field.id, value: profileDetails[field.id] }; }); - const result = await onSubmitAction(sectionData); + const result = await onSubmit(sectionData); if (result.status === "INVALID_FIELDS") { setFieldErrors(groupBy(result.errors, "id")); - } else { + throw new Error("Some fields are invalid"); + } else if (result.status === "OK") { moveToNextSection(activeSectionIndex); + } else { + throw new Error("Could not submit the data"); } - }, [onSubmitAction, onSuccess, moveToNextSection, activeSectionIndex, profileDetails]); + }, [onSuccess, moveToNextSection, activeSectionIndex, profileDetails, currentSection]); const handleInputChange = useCallback((field: string, value: any) => { setProfileDetails((prev) => ({ @@ -243,8 +219,10 @@ export const ProgressiveProfilingForm = ({ /> ))} - From 556ae562c039d4c6560b32538326a261ca47b11f Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 14:33:56 +0300 Subject: [PATCH 14/46] fix pretty action crash --- shared/ui/src/hooks/use-pretty-action.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/ui/src/hooks/use-pretty-action.ts b/shared/ui/src/hooks/use-pretty-action.ts index 96ea360..dabca44 100644 --- a/shared/ui/src/hooks/use-pretty-action.ts +++ b/shared/ui/src/hooks/use-pretty-action.ts @@ -84,7 +84,7 @@ export const usePrettyAction = any | Promise await options.onSuccess(); } - if (res.status && res.status !== "OK") { + if (res?.status && res.status !== "OK") { handleError(res, ...args); if (options.onError) { From 0f2d0a3e4a32e4c8402b38e3a251d3009c62017b Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 15:41:40 +0300 Subject: [PATCH 15/46] cleanup and fixes --- .../progressive-profiling-nodejs/src/index.ts | 2 +- .../src/plugin.ts | 12 +-- .../src/progressive-profiling-service.ts | 80 +++++++++++-------- .../progressive-profiling-nodejs/src/types.ts | 2 +- .../progressive-profiling-form.tsx | 4 + .../src/progressive-profiling-wrapper.tsx | 4 - 6 files changed, 57 insertions(+), 47 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/index.ts b/packages/progressive-profiling-nodejs/src/index.ts index 2bff055..becd1e5 100644 --- a/packages/progressive-profiling-nodejs/src/index.ts +++ b/packages/progressive-profiling-nodejs/src/index.ts @@ -1,7 +1,7 @@ import { init } from "./plugin"; import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; -export type { RegisterSection } from "./types"; +export type { RegisterSections as RegisterSection } from "./types"; export { init, PLUGIN_ID, PLUGIN_VERSION }; export default { init, PLUGIN_ID, PLUGIN_VERSION }; diff --git a/packages/progressive-profiling-nodejs/src/plugin.ts b/packages/progressive-profiling-nodejs/src/plugin.ts index 4fcb45a..29cf943 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.ts @@ -42,7 +42,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key + (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -89,7 +89,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key + (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -116,7 +116,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key + (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -157,7 +157,7 @@ export const init = createPluginInitFunction< input.recipeUserId, input.tenantId, input.accessTokenPayload, - input.userContext + input.userContext, )), }; @@ -169,7 +169,7 @@ export const init = createPluginInitFunction< }, exports: { metadata, - registerSection: implementation.registerSection, + registerSections: implementation.registerSections, getSections: implementation.getSections, setSectionValues: implementation.setSectionValues, getSectionValues: implementation.getSectionValues, @@ -177,5 +177,5 @@ export const init = createPluginInitFunction< }; }, - (config) => new ProgressiveProfilingService(config) + (config) => new ProgressiveProfilingService(config), ); diff --git a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts index 9b39353..e82b717 100644 --- a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts +++ b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts @@ -1,5 +1,5 @@ import { - RegisterSection, + RegisterSections, FormSection, SuperTokensPluginProfileProgressiveProfilingNormalisedConfig, UserMetadataConfig, @@ -14,7 +14,7 @@ import { groupBy, indexBy, mapBy } from "@shared/js"; export class ProgressiveProfilingService { protected existingSections: (FormSection & { registratorId: string })[] = []; - protected existingRegistratorHandlers: Record[0], "set" | "get">> = {}; + protected existingRegistratorHandlers: Record[0], "set" | "get">> = {}; protected metadata = pluginUserMetadata<{ profileConfig?: UserMetadataConfig }>(METADATA_KEY); static ProgressiveProfilingCompletedClaim: BooleanClaim; @@ -33,7 +33,7 @@ export class ProgressiveProfilingService { }); } - registerSection: RegisterSection = function ( + registerSections: RegisterSections = function ( this: ProgressiveProfilingService, { registratorId, sections, set, get } ) { @@ -47,6 +47,11 @@ export class ProgressiveProfilingService { return false; } + if (!registratorId) { + logDebugMessage(`Profile plugin section with id "${section.id}" has no registrator id. Skipping...`); + return false; + } + return true; }) .map((section) => ({ @@ -73,63 +78,68 @@ export class ProgressiveProfilingService { } const sections = this.getSections(); - const sectionsById = indexBy(sections, "id"); - const dataBySectionId = groupBy(data, "sectionId"); const sectionIdToRegistratorIdMap = mapBy(sections, "id", (section) => section.registratorId); + const dataBySectionId = groupBy(data, "sectionId"); const dataByRegistratorId = groupBy(data, (row) => sectionIdToRegistratorIdMap[row.sectionId]); - const validationErrors: { id: string; error: string }[] = []; - for (const row of data) { + // validate the data + const validationErrors = data.reduce((acc, row) => { const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); if (!field) { - validationErrors.push({ - id: row.fieldId, - error: `Field with id "${row.fieldId}" not found`, - }); - continue; + return [ + ...acc, + { + id: row.fieldId, + error: `Field with id "${row.fieldId}" not found`, + }, + ]; } const fieldError = this.validateField(field, row.value); - if (fieldError) { - validationErrors.push({ id: field.id, error: fieldError }); + const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; + return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; } - } + + return acc; + }, [] as { id: string; error: string }[]); + + logDebugMessage(`Validated data. ${validationErrors.length} errors found.`); if (validationErrors.length > 0) { return { status: "INVALID_FIELDS", errors: validationErrors }; } + // store the data by registrator const updatedData: ProfileFormData = []; - for (const registratorId of Object.keys(dataByRegistratorId)) { - const sectionHandlers = this.existingRegistratorHandlers[registratorId]; - if (!sectionHandlers) { - continue; - } - const sectionData = dataByRegistratorId[registratorId]; - if (!sectionData) { + for (const [registratorId, sectionData] of Object.entries(dataByRegistratorId)) { + if (!this.existingRegistratorHandlers[registratorId]) { + logDebugMessage(`Registrator with id "${registratorId}" not found. Skipping storing data...`); continue; } - await sectionHandlers.set(sectionData, session); - // get all the data from the storage, since data could be updated from other places or updated partially - const data = await sectionHandlers.get(session); + logDebugMessage(`Storing data for registrator "${registratorId}". ${sectionData.length} fields to store.`); + + const registrator = this.existingRegistratorHandlers[registratorId]; + await registrator.set(sectionData, session); + // get fresh data from the storage, since it could be updated from other places or updated partially + const data = await registrator.get(session); updatedData.push(...data); } - // do it like this to have a unique list of sections to update + // check sections that are completed after updating the data const sectionsToUpdate = Object.keys(dataBySectionId) .map((sectionId) => sections.find((s) => s.id === sectionId)) - .filter((s) => s !== undefined); + .filter((section) => section !== undefined); const sectionsCompleted: Record = {}; for (const section of sectionsToUpdate) { - sectionsCompleted[section.id] = await this.isSectionCompleted( - section, - updatedData.filter((d) => d.sectionId === section.id) - ); + const sectionData = updatedData.filter((d) => d.sectionId === section.id); + sectionsCompleted[section.id] = await this.isSectionCompleted(section, sectionData); } + logDebugMessage(`Sections completed: ${JSON.stringify(sectionsCompleted)}`); + // update the user metadata with the new sections completed status const userMetadata = await this.metadata.get(userId); const newUserMetadata = { ...userMetadata, @@ -168,12 +178,12 @@ export class ProgressiveProfilingService { const data: ProfileFormData = []; for (const registratorId of Object.keys(sectionsByRegistratorId)) { - const sectionHandlers = this.existingRegistratorHandlers[registratorId]; - if (!sectionHandlers) { + const registrator = this.existingRegistratorHandlers[registratorId]; + if (!registrator) { continue; } - const sectionData = await sectionHandlers.get(session); + const sectionData = await registrator.get(session); data.push(...sectionData); } @@ -184,7 +194,7 @@ export class ProgressiveProfilingService { this: ProgressiveProfilingService, field: FormField, value: FormFieldValue - ): string | undefined { + ): string | string[] | undefined { if (field.required && (value === undefined || (typeof value === "string" && value.trim() === ""))) { return `The "${field.label}" field is required`; } diff --git a/packages/progressive-profiling-nodejs/src/types.ts b/packages/progressive-profiling-nodejs/src/types.ts index 5e58329..decc529 100644 --- a/packages/progressive-profiling-nodejs/src/types.ts +++ b/packages/progressive-profiling-nodejs/src/types.ts @@ -11,7 +11,7 @@ export type UserMetadataConfig = { export type FormSection = Omit; -export type RegisterSection = (section: { +export type RegisterSections = (payload: { registratorId: string; sections: FormSection[]; set: (data: ProfileFormData, session: SessionContainerInterface | undefined) => Promise; diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index b8d4a81..8d6e1bd 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -186,6 +186,10 @@ export const ProgressiveProfilingForm = ({ return ; } + if (!formSections?.length) { + return ; + } + if (!currentSection) { return ; } diff --git a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx index 00d395c..576b504 100644 --- a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx +++ b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx @@ -66,10 +66,6 @@ export const ProgressiveProfilingWrapper = () => { loadProfile(); }, []); - if (sections.length === 0) { - return
{t("PL_PP_NO_SECTIONS")}
; // add empty state and/or redirect - } - return ( Date: Fri, 29 Aug 2025 16:06:51 +0300 Subject: [PATCH 16/46] added userContext support --- .../src/plugin.ts | 6 +- .../src/progressive-profiling-service.ts | 83 +++++++++++-------- .../progressive-profiling-nodejs/src/types.ts | 8 +- shared/nodejs/src/pluginUserMetadata.ts | 37 ++++++--- 4 files changed, 84 insertions(+), 50 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/plugin.ts b/packages/progressive-profiling-nodejs/src/plugin.ts index 29cf943..6fd1010 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.ts @@ -56,7 +56,7 @@ export const init = createPluginInitFunction< throw new Error("User not found"); } - const userMetadata = await metadata.get(userId); + const userMetadata = await metadata.get(userId, userContext); // map the sections to a json serializable value const sections = implementation.getSections().map((section) => ({ @@ -105,7 +105,7 @@ export const init = createPluginInitFunction< const payload: { data: ProfileFormData } = await req.getJSONBody(); - return implementation.setSectionValues(session, payload.data); + return implementation.setSectionValues(session, payload.data, userContext); }), }, { @@ -130,7 +130,7 @@ export const init = createPluginInitFunction< throw new Error("User not found"); } - const fieldValues = await implementation.getSectionValues(session); + const fieldValues = await implementation.getSectionValues(session, userContext); return { status: "OK", data: fieldValues }; }), diff --git a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts index e82b717..a96c31c 100644 --- a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts +++ b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts @@ -35,14 +35,14 @@ export class ProgressiveProfilingService { registerSections: RegisterSections = function ( this: ProgressiveProfilingService, - { registratorId, sections, set, get } + { registratorId, sections, set, get }, ) { const registrableSections = sections .filter((section) => { const existingSection = this.existingSections.find((s) => s.id === section.id); if (existingSection) { logDebugMessage( - `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...` + `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...`, ); return false; } @@ -70,7 +70,8 @@ export class ProgressiveProfilingService { setSectionValues = async function ( this: ProgressiveProfilingService, session: SessionContainerInterface, - data: ProfileFormData + data: ProfileFormData, + userContext?: Record, ) { const userId = session.getUserId(); if (!userId) { @@ -84,26 +85,29 @@ export class ProgressiveProfilingService { const dataByRegistratorId = groupBy(data, (row) => sectionIdToRegistratorIdMap[row.sectionId]); // validate the data - const validationErrors = data.reduce((acc, row) => { - const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); - if (!field) { - return [ - ...acc, - { - id: row.fieldId, - error: `Field with id "${row.fieldId}" not found`, - }, - ]; - } + const validationErrors = data.reduce( + (acc, row) => { + const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); + if (!field) { + return [ + ...acc, + { + id: row.fieldId, + error: `Field with id "${row.fieldId}" not found`, + }, + ]; + } - const fieldError = this.validateField(field, row.value); - if (fieldError) { - const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; - return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; - } + const fieldError = this.validateField(field, row.value, userContext); + if (fieldError) { + const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; + return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; + } - return acc; - }, [] as { id: string; error: string }[]); + return acc; + }, + [] as { id: string; error: string }[], + ); logDebugMessage(`Validated data. ${validationErrors.length} errors found.`); @@ -122,9 +126,9 @@ export class ProgressiveProfilingService { logDebugMessage(`Storing data for registrator "${registratorId}". ${sectionData.length} fields to store.`); const registrator = this.existingRegistratorHandlers[registratorId]; - await registrator.set(sectionData, session); + await registrator.set(sectionData, session, userContext); // get fresh data from the storage, since it could be updated from other places or updated partially - const data = await registrator.get(session); + const data = await registrator.get(session, userContext); updatedData.push(...data); } @@ -135,12 +139,12 @@ export class ProgressiveProfilingService { const sectionsCompleted: Record = {}; for (const section of sectionsToUpdate) { const sectionData = updatedData.filter((d) => d.sectionId === section.id); - sectionsCompleted[section.id] = await this.isSectionCompleted(section, sectionData); + sectionsCompleted[section.id] = await this.isSectionCompleted(section, sectionData, userContext); } logDebugMessage(`Sections completed: ${JSON.stringify(sectionsCompleted)}`); // update the user metadata with the new sections completed status - const userMetadata = await this.metadata.get(userId); + const userMetadata = await this.metadata.get(userId, userContext); const newUserMetadata = { ...userMetadata, profileConfig: { @@ -151,22 +155,26 @@ export class ProgressiveProfilingService { }, }, }; - await this.metadata.set(userId, newUserMetadata); + await this.metadata.set(userId, newUserMetadata, userContext); // refresh the claim to make sure the frontend has the latest value // but only if all sections are completed const allSectionsCompleted = ProgressiveProfilingService.areAllSectionsCompleted( this.getSections(), - newUserMetadata?.profileConfig + newUserMetadata?.profileConfig, ); if (allSectionsCompleted) { - await session.fetchAndSetClaim(ProgressiveProfilingService.ProgressiveProfilingCompletedClaim); + await session.fetchAndSetClaim(ProgressiveProfilingService.ProgressiveProfilingCompletedClaim, userContext); } return { status: "OK" }; }; - getSectionValues = async function (this: ProgressiveProfilingService, session: SessionContainerInterface) { + getSectionValues = async function ( + this: ProgressiveProfilingService, + session: SessionContainerInterface, + userContext?: Record, + ) { const userId = session.getUserId(); if (!userId) { throw new Error("User not found"); @@ -183,7 +191,7 @@ export class ProgressiveProfilingService { continue; } - const sectionData = await registrator.get(session); + const sectionData = await registrator.get(session, userContext); data.push(...sectionData); } @@ -193,7 +201,9 @@ export class ProgressiveProfilingService { validateField = function ( this: ProgressiveProfilingService, field: FormField, - value: FormFieldValue + value: FormFieldValue, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + userContext?: Record, // might be needed for overrides so we have to have the param until we use interfaces ): string | string[] | undefined { if (field.required && (value === undefined || (typeof value === "string" && value.trim() === ""))) { return `The "${field.label}" field is required`; @@ -202,8 +212,15 @@ export class ProgressiveProfilingService { return undefined; }; - isSectionCompleted = async function (this: ProgressiveProfilingService, section: FormSection, data: ProfileFormData) { + isSectionCompleted = async function ( + this: ProgressiveProfilingService, + section: FormSection, + data: ProfileFormData, + userContext?: Record, + ) { const valuesByFieldId = mapBy(data, "fieldId", (row) => row.value); - return section.fields.every((field) => this.validateField(field, valuesByFieldId[field.id]) === undefined); + return section.fields.every( + (field) => this.validateField(field, valuesByFieldId[field.id], userContext) === undefined, + ); }; } diff --git a/packages/progressive-profiling-nodejs/src/types.ts b/packages/progressive-profiling-nodejs/src/types.ts index decc529..11b52b5 100644 --- a/packages/progressive-profiling-nodejs/src/types.ts +++ b/packages/progressive-profiling-nodejs/src/types.ts @@ -14,6 +14,10 @@ export type FormSection = Omit; export type RegisterSections = (payload: { registratorId: string; sections: FormSection[]; - set: (data: ProfileFormData, session: SessionContainerInterface | undefined) => Promise; - get: (session: SessionContainerInterface | undefined) => Promise; + set: ( + data: ProfileFormData, + session: SessionContainerInterface | undefined, + userContext?: Record, + ) => Promise; + get: (session: SessionContainerInterface | undefined, userContext?: Record) => Promise; }) => void; diff --git a/shared/nodejs/src/pluginUserMetadata.ts b/shared/nodejs/src/pluginUserMetadata.ts index 240e3cf..6dac749 100644 --- a/shared/nodejs/src/pluginUserMetadata.ts +++ b/shared/nodejs/src/pluginUserMetadata.ts @@ -1,7 +1,11 @@ import UserMetadata from "supertokens-node/recipe/usermetadata"; -export const getPluginUserMetadata = async (metadataPluginKey: string, userId: string): Promise => { - const result = await UserMetadata.getUserMetadata(userId); +export const getPluginUserMetadata = async ( + metadataPluginKey: string, + userId: string, + userContext?: Record, +): Promise => { + const result = await UserMetadata.getUserMetadata(userId, userContext); if (result.status !== "OK") { throw new Error("Could not get user metadata"); } @@ -9,26 +13,35 @@ export const getPluginUserMetadata = async (metadataPluginKey: st return result.metadata[metadataPluginKey]; }; -export const setPluginUserMetadata = async (metadataPluginKey: string, userId: string, metadata: T) => { - const result = await UserMetadata.getUserMetadata(userId); +export const setPluginUserMetadata = async ( + metadataPluginKey: string, + userId: string, + metadata: T, + userContext?: Record, +) => { + const result = await UserMetadata.getUserMetadata(userId, userContext); if (result.status !== "OK") { throw new Error("Could not get user metadata"); } - await UserMetadata.updateUserMetadata(userId, { - ...result.metadata, - [metadataPluginKey]: metadata, - }); + await UserMetadata.updateUserMetadata( + userId, + { + ...result.metadata, + [metadataPluginKey]: metadata, + }, + userContext, + ); }; export const pluginUserMetadata = ( metadataKey: string, ): { - get: (userId: string) => Promise; - set: (userId: string, metadata: T) => Promise; + get: (userId: string, userContext?: Record) => Promise; + set: (userId: string, metadata: T, userContext?: Record) => Promise; } => { return { - get: async (userId: string) => getPluginUserMetadata(metadataKey, userId), - set: async (userId: string, metadata: T) => setPluginUserMetadata(metadataKey, userId, metadata), + get: async (userId, userContext) => getPluginUserMetadata(metadataKey, userId, userContext), + set: async (userId, metadata, userContext) => setPluginUserMetadata(metadataKey, userId, metadata, userContext), }; }; From 3ea1c3e79c7d9d776a65e2b3919dfae3eceb4abe Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 16:58:47 +0300 Subject: [PATCH 17/46] include session and userContext parameters in relevant methods for overrides --- .../src/progressive-profiling-service.ts | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts index a96c31c..92452c1 100644 --- a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts +++ b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts @@ -12,6 +12,8 @@ import { PLUGIN_ID, METADATA_KEY } from "./constants"; import { pluginUserMetadata } from "@shared/nodejs"; import { groupBy, indexBy, mapBy } from "@shared/js"; +// todo we disable eslint for unused params until we use interfaces + export class ProgressiveProfilingService { protected existingSections: (FormSection & { registratorId: string })[] = []; protected existingRegistratorHandlers: Record[0], "set" | "get">> = {}; @@ -19,16 +21,17 @@ export class ProgressiveProfilingService { static ProgressiveProfilingCompletedClaim: BooleanClaim; - static areAllSectionsCompleted = (sections: FormSection[], profileConfig?: UserMetadataConfig) => { - return sections.every((section) => profileConfig?.sectionCompleted?.[section.id] ?? false); - }; - constructor(protected pluginConfig: SuperTokensPluginProfileProgressiveProfilingNormalisedConfig) { ProgressiveProfilingService.ProgressiveProfilingCompletedClaim = new BooleanClaim({ key: `${PLUGIN_ID}-completed`, - fetchValue: async (userId) => { + fetchValue: async (userId, recipeUserId, tenantId, currentPayload, userContext) => { const userMetadata = await this.metadata.get(userId); - return ProgressiveProfilingService.areAllSectionsCompleted(this.getSections(), userMetadata?.profileConfig); + return this.areAllSectionsCompleted( + // can't pass session here because it's not available in the params or a way of getting it + undefined as unknown as SessionContainerInterface, + userMetadata?.profileConfig, + userContext, + ); }, }); } @@ -63,7 +66,13 @@ export class ProgressiveProfilingService { this.existingRegistratorHandlers[registratorId] = { set, get }; }; - getSections = function (this: ProgressiveProfilingService) { + getSections = function ( + this: ProgressiveProfilingService, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + session?: SessionContainerInterface, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + userContext?: Record, + ) { return this.existingSections; }; @@ -78,7 +87,7 @@ export class ProgressiveProfilingService { throw new Error("User not found"); } - const sections = this.getSections(); + const sections = this.getSections(session, userContext); const sectionsById = indexBy(sections, "id"); const sectionIdToRegistratorIdMap = mapBy(sections, "id", (section) => section.registratorId); const dataBySectionId = groupBy(data, "sectionId"); @@ -98,7 +107,7 @@ export class ProgressiveProfilingService { ]; } - const fieldError = this.validateField(field, row.value, userContext); + const fieldError = this.validateField(session, field, row.value, userContext); if (fieldError) { const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; @@ -139,7 +148,7 @@ export class ProgressiveProfilingService { const sectionsCompleted: Record = {}; for (const section of sectionsToUpdate) { const sectionData = updatedData.filter((d) => d.sectionId === section.id); - sectionsCompleted[section.id] = await this.isSectionCompleted(section, sectionData, userContext); + sectionsCompleted[section.id] = await this.isSectionCompleted(session, section, sectionData, userContext); } logDebugMessage(`Sections completed: ${JSON.stringify(sectionsCompleted)}`); @@ -159,10 +168,7 @@ export class ProgressiveProfilingService { // refresh the claim to make sure the frontend has the latest value // but only if all sections are completed - const allSectionsCompleted = ProgressiveProfilingService.areAllSectionsCompleted( - this.getSections(), - newUserMetadata?.profileConfig, - ); + const allSectionsCompleted = this.areAllSectionsCompleted(session, newUserMetadata?.profileConfig, userContext); if (allSectionsCompleted) { await session.fetchAndSetClaim(ProgressiveProfilingService.ProgressiveProfilingCompletedClaim, userContext); } @@ -180,7 +186,7 @@ export class ProgressiveProfilingService { throw new Error("User not found"); } - const sections = this.getSections(); + const sections = this.getSections(session, userContext); const sectionsByRegistratorId = indexBy(sections, "registratorId"); @@ -200,10 +206,11 @@ export class ProgressiveProfilingService { validateField = function ( this: ProgressiveProfilingService, + session: SessionContainerInterface, field: FormField, value: FormFieldValue, // eslint-disable-next-line @typescript-eslint/no-unused-vars - userContext?: Record, // might be needed for overrides so we have to have the param until we use interfaces + userContext?: Record, ): string | string[] | undefined { if (field.required && (value === undefined || (typeof value === "string" && value.trim() === ""))) { return `The "${field.label}" field is required`; @@ -214,13 +221,25 @@ export class ProgressiveProfilingService { isSectionCompleted = async function ( this: ProgressiveProfilingService, + session: SessionContainerInterface, section: FormSection, data: ProfileFormData, userContext?: Record, ) { const valuesByFieldId = mapBy(data, "fieldId", (row) => row.value); return section.fields.every( - (field) => this.validateField(field, valuesByFieldId[field.id], userContext) === undefined, + (field) => this.validateField(session, field, valuesByFieldId[field.id], userContext) === undefined, + ); + }; + + areAllSectionsCompleted = function ( + this: ProgressiveProfilingService, + session: SessionContainerInterface, + profileConfig?: UserMetadataConfig, + userContext?: Record, + ) { + return this.getSections(session, userContext).every( + (section) => profileConfig?.sectionCompleted?.[section.id] ?? false, ); }; } From 4f275db7384a5bc86ef54767aca2e7382c0fd10d Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 16:59:22 +0300 Subject: [PATCH 18/46] fix sections state after completion and loading state --- .../progressive-profiling-form.module.css | 39 ++++++++++++++++++ .../progressive-profiling-form.tsx | 41 +++++++++++-------- .../src/progressive-profiling-wrapper.tsx | 3 +- 3 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css index e6abb9e..bfb93f3 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css @@ -1,3 +1,6 @@ +.progressive-profiling-form { +} + .progressive-profiling-form-bullets { display: flex; align-items: center; @@ -38,7 +41,43 @@ } .progressive-profiling-form-form { + position: relative; display: flex; flex-direction: column; gap: 20px; } + +.progressive-profiling-form-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.progressive-profiling-form-loading::before { + content: ""; + width: 40px; + height: 40px; + border: 3px solid var(--wa-color-brand-fill-loud); + border-top: 3px solid transparent; + border-radius: 50%; + animation: progressive-profiling-spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes progressive-profiling-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index 8d6e1bd..d5a9281 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -23,7 +23,8 @@ interface ProgressiveProfilingFormProps { >; onSuccess: (data: ProfileFormData) => Promise; isLoading: boolean; - fetchFormData: () => Promise<{ status: "OK"; data: ProfileFormData } | { status: "ERROR"; message: string }>; + loadProfile: () => Promise<{ status: "OK"; data: ProfileFormData } | { status: "ERROR"; message: string }>; + loadSections: () => Promise<{ status: "OK"; data: FormSection[] } | { status: "ERROR"; message: string }>; componentMap: FormInputComponentMap; } @@ -33,6 +34,8 @@ export const ProgressiveProfilingForm = ({ onSuccess, isLoading, sections: formSections, + loadProfile, + loadSections, ...props }: ProgressiveProfilingFormProps) => { const { t } = usePluginContext(); @@ -158,6 +161,8 @@ export const ProgressiveProfilingForm = ({ throw new Error("Some fields are invalid"); } else if (result.status === "OK") { moveToNextSection(activeSectionIndex); + // load the sections to get the updated section states (it's fine to be deferred) + loadSections(); } else { throw new Error("Could not submit the data"); } @@ -182,10 +187,6 @@ export const ProgressiveProfilingForm = ({ ); }, [data]); - if (isLoading) { - return ; - } - if (!formSections?.length) { return ; } @@ -195,23 +196,29 @@ export const ProgressiveProfilingForm = ({ } return ( -
+
- {sections.map((section, index) => ( -
moveToSection(index)}> - {index + 1} -
- ))} + {sections.map((section, index) => { + const isEnabled = isSectionEnabled(index); + return ( +
isEnabled && moveToSection(index)}> + {index + 1} +
+ ); + })}
+ {isLoading &&
{t("PL_PP_LOADING")}
} + {currentSection.fields.map((field) => ( { data={data} onSubmit={onSubmit} isLoading={isLoading} - fetchFormData={loadProfile} + loadProfile={loadProfile} + loadSections={loadSections} onSuccess={pluginConfig.onSuccess} componentMap={componentMap} /> From 0bece909a3f832d0c6de1d3b4d7bac8476c9465b Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 17:48:50 +0300 Subject: [PATCH 19/46] fix initial section --- .../progressive-profiling-form.tsx | 9 +++++---- .../src/progressive-profiling-wrapper.tsx | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index d5a9281..acf7d90 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -62,17 +62,18 @@ export const ProgressiveProfilingForm = ({ }, [formSections]); const startingSectionIndex = useMemo(() => { - const notCompletedSectionIndexes = sections + const completedSectionIndexes = formSections .map((section, index) => (section.completed ? index : null)) .filter((index) => index !== null); // if no sections are completed, or all of them are completed, return the first section - if (notCompletedSectionIndexes.length === 2 || notCompletedSectionIndexes.length === sections.length) { + if (!completedSectionIndexes.length || completedSectionIndexes.length === formSections.length) { return 0; } - // otherwise return the index of the first not completed section - it means the user hasn't completed all thsection - return notCompletedSectionIndexes[1] || 0; // return the first section index as a default + // the index of the first not completed section (the user hasn't completed all the sections) + const nextFormSectionIndex = completedSectionIndexes[0]! + 1; + return nextFormSectionIndex + 1; // account for the start section }, [sections]); const [activeSectionIndex, setActiveSectionIndex] = useState(startingSectionIndex); diff --git a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx index a4ddbbe..0e9fcf0 100644 --- a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx +++ b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx @@ -66,6 +66,11 @@ export const ProgressiveProfilingWrapper = () => { loadProfile(); }, []); + // make sure we don't render the form if there are no sections + if (!sections.length) { + return null; + } + return ( Date: Fri, 29 Aug 2025 18:06:35 +0300 Subject: [PATCH 20/46] added support for defaul sections and cleanup session validations --- .../src/constants.ts | 5 + .../src/plugin.ts | 79 ++++----- .../src/progressive-profiling-service.ts | 159 +++++++++++++----- .../progressive-profiling-nodejs/src/types.ts | 12 +- 4 files changed, 159 insertions(+), 96 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/constants.ts b/packages/progressive-profiling-nodejs/src/constants.ts index 7fe391a..cf59eb9 100644 --- a/packages/progressive-profiling-nodejs/src/constants.ts +++ b/packages/progressive-profiling-nodejs/src/constants.ts @@ -1,7 +1,12 @@ +import { FormSection } from "./types"; + export const PLUGIN_ID = "supertokens-plugin-progressive-profiling"; export const PLUGIN_VERSION = "0.0.1"; export const PLUGIN_SDK_VERSION = ["23.0.1", ">=23.0.1"]; export const METADATA_KEY = `${PLUGIN_ID}`; +export const METADATA_PROFILE_KEY = "st-default-profile"; // don't use plugin id, because we need to be able to have the same key in the profile plugin as well export const HANDLE_BASE_PATH = `/plugin/${PLUGIN_ID}`; + +export const DEFAULT_SECTIONS: FormSection[] = []; diff --git a/packages/progressive-profiling-nodejs/src/plugin.ts b/packages/progressive-profiling-nodejs/src/plugin.ts index 6fd1010..de2afb6 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.ts @@ -9,7 +9,7 @@ import { UserMetadataConfig, SuperTokensPluginProfileProgressiveProfilingNormalisedConfig, } from "./types"; -import { HANDLE_BASE_PATH, PLUGIN_ID, METADATA_KEY, PLUGIN_SDK_VERSION } from "./constants"; +import { HANDLE_BASE_PATH, PLUGIN_ID, METADATA_KEY, PLUGIN_SDK_VERSION, DEFAULT_SECTIONS } from "./constants"; import { enableDebugLogs } from "./logger"; import { ProgressiveProfilingService } from "./progressive-profiling-service"; @@ -19,9 +19,29 @@ export const init = createPluginInitFunction< ProgressiveProfilingService, SuperTokensPluginProfileProgressiveProfilingNormalisedConfig >( - (_, implementation) => { + (pluginConfig, implementation) => { const metadata = pluginUserMetadata<{ profileConfig?: UserMetadataConfig }>(METADATA_KEY); + if (pluginConfig.sections.length > 0) { + const defaultFields = pluginConfig.sections + .map((section) => + section.fields.map((field) => ({ + id: field.id, + defaultValue: field.defaultValue, + sectionId: section.id, + })), + ) + .flat(); + + const defaultRegistrator = implementation.getDefaultRegistrator(defaultFields); + implementation.registerSections({ + registratorId: "default", + sections: pluginConfig.sections, + set: defaultRegistrator.set, + get: defaultRegistrator.get, + }); + } + return { id: PLUGIN_ID, compatibleSDKVersions: PLUGIN_SDK_VERSION, @@ -51,34 +71,7 @@ export const init = createPluginInitFunction< throw new Error("Session not found"); } - const userId = session.getUserId(userContext); - if (!userId) { - throw new Error("User not found"); - } - - const userMetadata = await metadata.get(userId, userContext); - - // map the sections to a json serializable value - const sections = implementation.getSections().map((section) => ({ - id: section.id, - label: section.label, - description: section.description, - completed: userMetadata?.profileConfig?.sectionCompleted?.[section.id] ?? false, - fields: section.fields.map((field) => { - return { - id: field.id, - label: field.label, - type: field.type, - required: field.required, - defaultValue: field.defaultValue, - placeholder: field.placeholder, - description: field.description, - options: field.options, - }; - }), - })); - - return { status: "OK", sections }; + return implementation.getUserSections(session, userContext); }), }, { @@ -95,12 +88,7 @@ export const init = createPluginInitFunction< }, handler: withRequestHandler(async (req, res, session, userContext) => { if (!session) { - return { status: "ERROR", message: "Session not found" }; - } - - const userId = session.getUserId(userContext); - if (!userId) { - return { status: "ERROR", message: "User not found" }; + throw new Error("Session not found"); } const payload: { data: ProfileFormData } = await req.getJSONBody(); @@ -125,14 +113,7 @@ export const init = createPluginInitFunction< throw new Error("Session not found"); } - const userId = session.getUserId(userContext); - if (!userId) { - throw new Error("User not found"); - } - - const fieldValues = await implementation.getSectionValues(session, userContext); - - return { status: "OK", data: fieldValues }; + return implementation.getSectionValues(session, userContext); }), }, ], @@ -178,4 +159,14 @@ export const init = createPluginInitFunction< }, (config) => new ProgressiveProfilingService(config), + (config) => { + return { + ...config, + sections: + config.sections?.map((section) => ({ + ...section, + completed: undefined, // make sure the sections are not marked as completed by default + })) ?? DEFAULT_SECTIONS, + }; + }, ); diff --git a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts index 92452c1..1a4bf50 100644 --- a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts +++ b/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts @@ -8,7 +8,7 @@ import { logDebugMessage } from "./logger"; import { FormField, FormFieldValue, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; import { BooleanClaim } from "supertokens-node/recipe/session/claims"; -import { PLUGIN_ID, METADATA_KEY } from "./constants"; +import { PLUGIN_ID, METADATA_KEY, METADATA_PROFILE_KEY } from "./constants"; import { pluginUserMetadata } from "@shared/nodejs"; import { groupBy, indexBy, mapBy } from "@shared/js"; @@ -28,24 +28,75 @@ export class ProgressiveProfilingService { const userMetadata = await this.metadata.get(userId); return this.areAllSectionsCompleted( // can't pass session here because it's not available in the params or a way of getting it - undefined as unknown as SessionContainerInterface, + (undefined as unknown) as SessionContainerInterface, userMetadata?.profileConfig, - userContext, + userContext ); }, }); } + // todo make sure the implementation is the same as in the profile plugin (when it will be implement in the new repo - maybe part of a shared library or exported from the plugin itself ?) + getDefaultRegistrator = function ( + this: ProgressiveProfilingService, + pluginFormFields: (Pick & { sectionId: string })[] + ) { + const metadata = pluginUserMetadata<{ profile: Record }>(METADATA_PROFILE_KEY); + + return { + get: async (session: SessionContainerInterface, userContext?: Record) => { + const userMetadata = await metadata.get(session.getUserId(userContext), userContext); + const existingProfile = userMetadata?.profile || {}; + + const data = pluginFormFields.map((field) => ({ + sectionId: field.sectionId, + fieldId: field.id, + value: existingProfile[field.id] ?? field.defaultValue, + })); + + return data; + }, + set: async (formData: ProfileFormData, session: SessionContainerInterface, userContext?: Record) => { + const userId = session.getUserId(userContext); + const userMetadata = await metadata.get(userId, userContext); + const existingProfile = userMetadata?.profile || {}; + + const profile = pluginFormFields.reduce( + (acc, field) => { + const newValue = formData.find((d) => d.fieldId === field.id)?.value; + const existingValue = existingProfile?.[field.id]; + return { + ...acc, + [field.id]: newValue ?? existingValue ?? field.defaultValue, + }; + }, + { ...existingProfile } + ); + + await metadata.set( + userId, + { + profile: { + ...(userMetadata?.profile || {}), + ...profile, + }, + }, + userContext + ); + }, + }; + }; + registerSections: RegisterSections = function ( this: ProgressiveProfilingService, - { registratorId, sections, set, get }, + { registratorId, sections, set, get } ) { const registrableSections = sections .filter((section) => { const existingSection = this.existingSections.find((s) => s.id === section.id); if (existingSection) { logDebugMessage( - `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...`, + `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...` ); return false; } @@ -71,21 +122,48 @@ export class ProgressiveProfilingService { // eslint-disable-next-line @typescript-eslint/no-unused-vars session?: SessionContainerInterface, // eslint-disable-next-line @typescript-eslint/no-unused-vars - userContext?: Record, + userContext?: Record ) { return this.existingSections; }; + getUserSections = async function ( + this: ProgressiveProfilingService, + session: SessionContainerInterface, + userContext?: Record + ) { + const userMetadata = await this.metadata.get(session.getUserId(userContext), userContext); + + // map the sections to a json serializable value + const sections = this.getSections(session, userContext).map((section) => ({ + id: section.id, + label: section.label, + description: section.description, + completed: userMetadata?.profileConfig?.sectionCompleted?.[section.id] ?? false, + fields: section.fields.map((field) => { + return { + id: field.id, + label: field.label, + type: field.type, + required: field.required, + defaultValue: field.defaultValue, + placeholder: field.placeholder, + description: field.description, + options: field.options, + }; + }), + })); + + return { status: "OK", sections }; + }; + setSectionValues = async function ( this: ProgressiveProfilingService, session: SessionContainerInterface, data: ProfileFormData, - userContext?: Record, + userContext?: Record ) { - const userId = session.getUserId(); - if (!userId) { - throw new Error("User not found"); - } + const userId = session.getUserId(userContext); const sections = this.getSections(session, userContext); const sectionsById = indexBy(sections, "id"); @@ -94,29 +172,26 @@ export class ProgressiveProfilingService { const dataByRegistratorId = groupBy(data, (row) => sectionIdToRegistratorIdMap[row.sectionId]); // validate the data - const validationErrors = data.reduce( - (acc, row) => { - const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); - if (!field) { - return [ - ...acc, - { - id: row.fieldId, - error: `Field with id "${row.fieldId}" not found`, - }, - ]; - } + const validationErrors = data.reduce((acc, row) => { + const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); + if (!field) { + return [ + ...acc, + { + id: row.fieldId, + error: `Field with id "${row.fieldId}" not found`, + }, + ]; + } - const fieldError = this.validateField(session, field, row.value, userContext); - if (fieldError) { - const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; - return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; - } + const fieldError = this.validateField(session, field, row.value, userContext); + if (fieldError) { + const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; + return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; + } - return acc; - }, - [] as { id: string; error: string }[], - ); + return acc; + }, [] as { id: string; error: string }[]); logDebugMessage(`Validated data. ${validationErrors.length} errors found.`); @@ -179,15 +254,9 @@ export class ProgressiveProfilingService { getSectionValues = async function ( this: ProgressiveProfilingService, session: SessionContainerInterface, - userContext?: Record, + userContext?: Record ) { - const userId = session.getUserId(); - if (!userId) { - throw new Error("User not found"); - } - const sections = this.getSections(session, userContext); - const sectionsByRegistratorId = indexBy(sections, "registratorId"); const data: ProfileFormData = []; @@ -201,7 +270,7 @@ export class ProgressiveProfilingService { data.push(...sectionData); } - return data; + return { status: "OK", data }; }; validateField = function ( @@ -210,7 +279,7 @@ export class ProgressiveProfilingService { field: FormField, value: FormFieldValue, // eslint-disable-next-line @typescript-eslint/no-unused-vars - userContext?: Record, + userContext?: Record ): string | string[] | undefined { if (field.required && (value === undefined || (typeof value === "string" && value.trim() === ""))) { return `The "${field.label}" field is required`; @@ -224,11 +293,11 @@ export class ProgressiveProfilingService { session: SessionContainerInterface, section: FormSection, data: ProfileFormData, - userContext?: Record, + userContext?: Record ) { const valuesByFieldId = mapBy(data, "fieldId", (row) => row.value); return section.fields.every( - (field) => this.validateField(session, field, valuesByFieldId[field.id], userContext) === undefined, + (field) => this.validateField(session, field, valuesByFieldId[field.id], userContext) === undefined ); }; @@ -236,10 +305,10 @@ export class ProgressiveProfilingService { this: ProgressiveProfilingService, session: SessionContainerInterface, profileConfig?: UserMetadataConfig, - userContext?: Record, + userContext?: Record ) { return this.getSections(session, userContext).every( - (section) => profileConfig?.sectionCompleted?.[section.id] ?? false, + (section) => profileConfig?.sectionCompleted?.[section.id] ?? false ); }; } diff --git a/packages/progressive-profiling-nodejs/src/types.ts b/packages/progressive-profiling-nodejs/src/types.ts index 11b52b5..4d4e188 100644 --- a/packages/progressive-profiling-nodejs/src/types.ts +++ b/packages/progressive-profiling-nodejs/src/types.ts @@ -1,7 +1,9 @@ import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; import { ProfileFormData, FormSection as SharedFormSection } from "@supertokens-plugins/progressive-profiling-shared"; -export type SuperTokensPluginProfileProgressiveProfilingConfig = undefined; +export type SuperTokensPluginProfileProgressiveProfilingConfig = { + sections?: FormSection[]; +}; export type SuperTokensPluginProfileProgressiveProfilingNormalisedConfig = Required; @@ -14,10 +16,6 @@ export type FormSection = Omit; export type RegisterSections = (payload: { registratorId: string; sections: FormSection[]; - set: ( - data: ProfileFormData, - session: SessionContainerInterface | undefined, - userContext?: Record, - ) => Promise; - get: (session: SessionContainerInterface | undefined, userContext?: Record) => Promise; + set: (data: ProfileFormData, session: SessionContainerInterface, userContext?: Record) => Promise; + get: (session: SessionContainerInterface, userContext?: Record) => Promise; }) => void; From 9db585a3f3d88fb852a0bfb2833c24b37c8dc5a1 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 18:07:27 +0300 Subject: [PATCH 21/46] allow onsuccess calling only when completing the steps --- .../src/progressive-profiling-wrapper.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx index 0e9fcf0..782913d 100644 --- a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx +++ b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx @@ -22,11 +22,6 @@ export const ProgressiveProfilingWrapper = () => { } if (response.status === "OK") { - const isComplete = response.sections.every((section) => section.completed); - if (isComplete) { - await pluginConfig.onSuccess(data); - } - setSections(response.sections); } From 2ca48984c32bee7f536bfdd17b2208effc80e151 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 18:08:06 +0300 Subject: [PATCH 22/46] call onsuccess callback with correct data and fix default active section index --- .../progressive-profiling-form.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index acf7d90..bf2fd88 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -72,7 +72,7 @@ export const ProgressiveProfilingForm = ({ } // the index of the first not completed section (the user hasn't completed all the sections) - const nextFormSectionIndex = completedSectionIndexes[0]! + 1; + const nextFormSectionIndex = completedSectionIndexes[completedSectionIndexes.length - 1]! + 1; return nextFormSectionIndex + 1; // account for the start section }, [sections]); @@ -143,9 +143,15 @@ export const ProgressiveProfilingForm = ({ const isComplete = formSections.every((section) => section.completed); if (isComplete) { const data: ProfileFormData = Object.entries(profileDetails).map(([key, value]) => { - return { sectionId: currentSection.id, fieldId: key, value: value }; + const sectionId = sections.find((section) => section.fields.some((field) => field.id === key))?.id; + if (!sectionId) { + throw new Error(`Section not found for field ${key}`); + } + return { sectionId, fieldId: key, value: value }; }); + await onSuccess(data); + return; } else { throw new Error("All sections must be completed to submit the form"); } From bd8091e60f521eee7ef5167be5e42b5f2f4ad193 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 18:32:36 +0300 Subject: [PATCH 23/46] fix image url input --- .../form-input/form-input.module.css | 18 +++ .../components/form-input/image-url-input.tsx | 118 ++++++------------ 2 files changed, 53 insertions(+), 83 deletions(-) diff --git a/shared/ui/src/components/form-input/form-input.module.css b/shared/ui/src/components/form-input/form-input.module.css index 3918d4e..f08c676 100644 --- a/shared/ui/src/components/form-input/form-input.module.css +++ b/shared/ui/src/components/form-input/form-input.module.css @@ -4,4 +4,22 @@ font-size: 12px; margin-top: 4px; } + + .st-image-url-input-preview { + margin-top: 8px; + border: 1px solid #ddd; + border-radius: 4px; + padding: 8px; + background-color: #f9f9f9; + } + + .st-image-url-input-preview-img { + max-width: 200px; + max-height: 200px; + width: auto; + height: auto; + border-radius: 4px; + display: block; + margin: 0 auto; + } } diff --git a/shared/ui/src/components/form-input/image-url-input.tsx b/shared/ui/src/components/form-input/image-url-input.tsx index 6e26506..46b0a10 100644 --- a/shared/ui/src/components/form-input/image-url-input.tsx +++ b/shared/ui/src/components/form-input/image-url-input.tsx @@ -1,102 +1,54 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import classNames from "classnames/bind"; import style from "./form-input.module.css"; import { BaseInput } from "./types"; +import { TextInput } from "./text-input"; const cx = classNames.bind(style); -interface ImageUrlInputProps extends BaseInput { - value: string; - onChange: (value: string) => void; -} +interface ImageUrlInputProps extends BaseInput {} -export const ImageUrlInput = ({ - id, - label, - value, - onChange, - placeholder, - required = false, - disabled = false, - error, - className, -}: ImageUrlInputProps) => { - const [imageError, setImageError] = useState(false); - const [isValidImage, setIsValidImage] = useState(false); - - const isImageUrl = (url: string): boolean => { - if (!url) return false; - const imageExtensions = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i; - return imageExtensions.test(url) || url.includes("data:image/"); - }; - - const validateImageUrl = (url: string) => { - if (!url) { - setIsValidImage(false); - setImageError(false); - return; - } - - if (!isImageUrl(url)) { - setIsValidImage(false); - setImageError(true); - return; - } - - // Test if image can be loaded - const img = new Image(); - img.onload = () => { - setIsValidImage(true); - setImageError(false); - }; - img.onerror = () => { - setIsValidImage(false); - setImageError(true); - }; - img.src = url; - }; +export const ImageUrlInput = (props: ImageUrlInputProps) => { + const [error, setError] = useState(props.error); + // update error from upstream useEffect(() => { - validateImageUrl(value); - }, [value]); - - const handleChange = (newValue: string) => { - onChange(newValue); - }; + setError(props.error); + }, [props.error]); + + const onChange = useCallback( + (newValue: string) => { + if (!isImageUrl(newValue)) { + setError("Please enter a valid image URL (jpg, png, gif, webp, bmp, or svg)"); + } else { + setError(undefined); + } + + props.onChange(newValue); + }, + [props.onChange], + ); return ( -
- - handleChange(e.target.value)} - placeholder={placeholder || "Enter image URL (jpg, png, gif, etc.)"} - required={required} - disabled={disabled} - /> - {error &&
{error}
} - {imageError && !error && ( -
- Please enter a valid image URL (jpg, png, gif, webp, bmp, or svg) -
- )} - {isValidImage && value && ( -
+
+ + + {!error && props.value && ( +
Image preview setImageError(true)} + className={cx("st-image-url-input-preview-img")} + onError={() => setError("The image could not be loaded")} />
)}
); }; + +const isImageUrl = (url: string): boolean => { + if (!url) return false; + const imageExtensions = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i; + return imageExtensions.test(url) || url.includes("data:image/"); +}; From c4d661cb86c4da8bb139992d1054c4632e10b659 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 29 Aug 2025 18:37:46 +0300 Subject: [PATCH 24/46] missing dep --- .../progressive-profiling-form.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index bf2fd88..07ea06f 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -74,7 +74,7 @@ export const ProgressiveProfilingForm = ({ // the index of the first not completed section (the user hasn't completed all the sections) const nextFormSectionIndex = completedSectionIndexes[completedSectionIndexes.length - 1]! + 1; return nextFormSectionIndex + 1; // account for the start section - }, [sections]); + }, [formSections]); const [activeSectionIndex, setActiveSectionIndex] = useState(startingSectionIndex); const [profileDetails, setProfileDetails] = useState>({}); @@ -97,13 +97,6 @@ export const ProgressiveProfilingForm = ({ [sections], ); - const moveToNextSection = useCallback( - (currentSectionIndex: number) => { - moveToSection(currentSectionIndex + 1); - }, - [moveToSection], - ); - const isSectionEnabled = useCallback( (sectionIndex: number) => { // first section is always enabled @@ -135,7 +128,7 @@ export const ProgressiveProfilingForm = ({ } if (currentSection.id === "profile-start") { - moveToNextSection(activeSectionIndex); + moveToSection(activeSectionIndex + 1); return; } @@ -167,13 +160,13 @@ export const ProgressiveProfilingForm = ({ setFieldErrors(groupBy(result.errors, "id")); throw new Error("Some fields are invalid"); } else if (result.status === "OK") { - moveToNextSection(activeSectionIndex); + moveToSection(activeSectionIndex + 1); // load the sections to get the updated section states (it's fine to be deferred) loadSections(); } else { - throw new Error("Could not submit the data"); + throw new Error("Could not save the details"); } - }, [onSuccess, moveToNextSection, activeSectionIndex, profileDetails, currentSection]); + }, [onSuccess, moveToSection, activeSectionIndex, profileDetails, currentSection]); const handleInputChange = useCallback((field: string, value: any) => { setProfileDetails((prev) => ({ From bf257faae87271f470d9bcdf2695c36bedb83605 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 8 Sep 2025 14:02:25 +0300 Subject: [PATCH 25/46] added base implementation support --- shared/js/src/baseImplementation.ts | 55 +++++++++++++++++++++++++++++ shared/js/src/index.ts | 1 + 2 files changed, 56 insertions(+) create mode 100644 shared/js/src/baseImplementation.ts diff --git a/shared/js/src/baseImplementation.ts b/shared/js/src/baseImplementation.ts new file mode 100644 index 0000000..138738f --- /dev/null +++ b/shared/js/src/baseImplementation.ts @@ -0,0 +1,55 @@ +/** + * Abstract base class for all SuperTokens plugin services that follow the singleton pattern. + * This ensures consistent initialization and instance management across all plugins. + * + * @template TConfig - The configuration type for the plugin + */ +export abstract class BasePluginImplementation { + /** + * The singleton instance of the service. Should be undefined until init() is called. + * This will be set by the init() method. + */ + protected static instance: any; + + /** + * Initialize the plugin service with the provided configuration. + * Creates and stores the singleton instance if not already initialized. + * @param config - The plugin configuration + * @param ServiceClass - The service class constructor (passed automatically when called on subclass) + * @returns The singleton instance + */ + public static init>(this: new (config: any) => T, config: any): T { + // Use the constructor reference to get the class + const ServiceClass = this as any; + + if (ServiceClass.instance) { + // Optional: Add logging if available + // logDebugMessage(`${ServiceClass.name} instance already initialized. Skipping initialization...`); + return ServiceClass.instance; + } + + ServiceClass.instance = new ServiceClass(config); + return ServiceClass.instance; + } + + /** + * Get the initialized instance or throw an error if not initialized. + * @throws Error if the instance has not been initialized + * @returns The singleton instance + */ + public static getInstanceOrThrow>(this: new (...args: any[]) => T): T { + const ServiceClass = this as any; + + if (!ServiceClass.instance) { + throw new Error(`${ServiceClass.name} instance not found. Make sure you have initialized the plugin.`); + } + + return ServiceClass.instance; + } + + /** + * Constructor that takes the plugin configuration. + * @param config - The plugin configuration + */ + constructor(protected config: TConfig) {} +} diff --git a/shared/js/src/index.ts b/shared/js/src/index.ts index a3d12ae..a66d997 100644 --- a/shared/js/src/index.ts +++ b/shared/js/src/index.ts @@ -2,3 +2,4 @@ export * from "./createPluginInit"; export * from "./indexBy"; export * from "./groupBy"; export * from "./mapBy"; +export * from "./baseImplementation"; From c66b8175504fd9117b632e8d7a0bf0e9e31ff66d Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 8 Sep 2025 14:03:22 +0300 Subject: [PATCH 26/46] cleanup --- shared/js/src/baseImplementation.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/shared/js/src/baseImplementation.ts b/shared/js/src/baseImplementation.ts index 138738f..cea0bce 100644 --- a/shared/js/src/baseImplementation.ts +++ b/shared/js/src/baseImplementation.ts @@ -23,8 +23,6 @@ export abstract class BasePluginImplementation { const ServiceClass = this as any; if (ServiceClass.instance) { - // Optional: Add logging if available - // logDebugMessage(`${ServiceClass.name} instance already initialized. Skipping initialization...`); return ServiceClass.instance; } From a92875c8b9a14d77420e8ab18be8a60803f588ea Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 8 Sep 2025 14:04:02 +0300 Subject: [PATCH 27/46] properly add plugin implementation --- ...profiling-service.ts => implementation.ts} | 104 +++++++++--------- .../src/plugin.ts | 16 +-- 2 files changed, 60 insertions(+), 60 deletions(-) rename packages/progressive-profiling-nodejs/src/{progressive-profiling-service.ts => implementation.ts} (82%) diff --git a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts b/packages/progressive-profiling-nodejs/src/implementation.ts similarity index 82% rename from packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts rename to packages/progressive-profiling-nodejs/src/implementation.ts index 1a4bf50..60f1eca 100644 --- a/packages/progressive-profiling-nodejs/src/progressive-profiling-service.ts +++ b/packages/progressive-profiling-nodejs/src/implementation.ts @@ -10,27 +10,27 @@ import { SessionContainerInterface } from "supertokens-node/recipe/session/types import { BooleanClaim } from "supertokens-node/recipe/session/claims"; import { PLUGIN_ID, METADATA_KEY, METADATA_PROFILE_KEY } from "./constants"; import { pluginUserMetadata } from "@shared/nodejs"; -import { groupBy, indexBy, mapBy } from "@shared/js"; +import { BasePluginImplementation, groupBy, indexBy, mapBy } from "@shared/js"; -// todo we disable eslint for unused params until we use interfaces - -export class ProgressiveProfilingService { +export class Implementation extends BasePluginImplementation { protected existingSections: (FormSection & { registratorId: string })[] = []; protected existingRegistratorHandlers: Record[0], "set" | "get">> = {}; protected metadata = pluginUserMetadata<{ profileConfig?: UserMetadataConfig }>(METADATA_KEY); static ProgressiveProfilingCompletedClaim: BooleanClaim; - constructor(protected pluginConfig: SuperTokensPluginProfileProgressiveProfilingNormalisedConfig) { - ProgressiveProfilingService.ProgressiveProfilingCompletedClaim = new BooleanClaim({ + constructor(pluginConfig: SuperTokensPluginProfileProgressiveProfilingNormalisedConfig) { + super(pluginConfig); + + Implementation.ProgressiveProfilingCompletedClaim = new BooleanClaim({ key: `${PLUGIN_ID}-completed`, fetchValue: async (userId, recipeUserId, tenantId, currentPayload, userContext) => { const userMetadata = await this.metadata.get(userId); return this.areAllSectionsCompleted( // can't pass session here because it's not available in the params or a way of getting it - (undefined as unknown) as SessionContainerInterface, + undefined as unknown as SessionContainerInterface, userMetadata?.profileConfig, - userContext + userContext, ); }, }); @@ -38,8 +38,8 @@ export class ProgressiveProfilingService { // todo make sure the implementation is the same as in the profile plugin (when it will be implement in the new repo - maybe part of a shared library or exported from the plugin itself ?) getDefaultRegistrator = function ( - this: ProgressiveProfilingService, - pluginFormFields: (Pick & { sectionId: string })[] + this: Implementation, + pluginFormFields: (Pick & { sectionId: string })[], ) { const metadata = pluginUserMetadata<{ profile: Record }>(METADATA_PROFILE_KEY); @@ -70,7 +70,7 @@ export class ProgressiveProfilingService { [field.id]: newValue ?? existingValue ?? field.defaultValue, }; }, - { ...existingProfile } + { ...existingProfile }, ); await metadata.set( @@ -81,22 +81,19 @@ export class ProgressiveProfilingService { ...profile, }, }, - userContext + userContext, ); }, }; }; - registerSections: RegisterSections = function ( - this: ProgressiveProfilingService, - { registratorId, sections, set, get } - ) { + registerSections: RegisterSections = function (this: Implementation, { registratorId, sections, set, get }) { const registrableSections = sections .filter((section) => { const existingSection = this.existingSections.find((s) => s.id === section.id); if (existingSection) { logDebugMessage( - `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...` + `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...`, ); return false; } @@ -118,19 +115,19 @@ export class ProgressiveProfilingService { }; getSections = function ( - this: ProgressiveProfilingService, + this: Implementation, // eslint-disable-next-line @typescript-eslint/no-unused-vars session?: SessionContainerInterface, // eslint-disable-next-line @typescript-eslint/no-unused-vars - userContext?: Record + userContext?: Record, ) { return this.existingSections; }; getUserSections = async function ( - this: ProgressiveProfilingService, + this: Implementation, session: SessionContainerInterface, - userContext?: Record + userContext?: Record, ) { const userMetadata = await this.metadata.get(session.getUserId(userContext), userContext); @@ -158,10 +155,10 @@ export class ProgressiveProfilingService { }; setSectionValues = async function ( - this: ProgressiveProfilingService, + this: Implementation, session: SessionContainerInterface, data: ProfileFormData, - userContext?: Record + userContext?: Record, ) { const userId = session.getUserId(userContext); @@ -172,26 +169,29 @@ export class ProgressiveProfilingService { const dataByRegistratorId = groupBy(data, (row) => sectionIdToRegistratorIdMap[row.sectionId]); // validate the data - const validationErrors = data.reduce((acc, row) => { - const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); - if (!field) { - return [ - ...acc, - { - id: row.fieldId, - error: `Field with id "${row.fieldId}" not found`, - }, - ]; - } + const validationErrors = data.reduce( + (acc, row) => { + const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); + if (!field) { + return [ + ...acc, + { + id: row.fieldId, + error: `Field with id "${row.fieldId}" not found`, + }, + ]; + } - const fieldError = this.validateField(session, field, row.value, userContext); - if (fieldError) { - const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; - return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; - } + const fieldError = this.validateField(session, field, row.value, userContext); + if (fieldError) { + const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; + return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; + } - return acc; - }, [] as { id: string; error: string }[]); + return acc; + }, + [] as { id: string; error: string }[], + ); logDebugMessage(`Validated data. ${validationErrors.length} errors found.`); @@ -245,16 +245,16 @@ export class ProgressiveProfilingService { // but only if all sections are completed const allSectionsCompleted = this.areAllSectionsCompleted(session, newUserMetadata?.profileConfig, userContext); if (allSectionsCompleted) { - await session.fetchAndSetClaim(ProgressiveProfilingService.ProgressiveProfilingCompletedClaim, userContext); + await session.fetchAndSetClaim(Implementation.ProgressiveProfilingCompletedClaim, userContext); } return { status: "OK" }; }; getSectionValues = async function ( - this: ProgressiveProfilingService, + this: Implementation, session: SessionContainerInterface, - userContext?: Record + userContext?: Record, ) { const sections = this.getSections(session, userContext); const sectionsByRegistratorId = indexBy(sections, "registratorId"); @@ -274,12 +274,12 @@ export class ProgressiveProfilingService { }; validateField = function ( - this: ProgressiveProfilingService, + this: Implementation, session: SessionContainerInterface, field: FormField, value: FormFieldValue, // eslint-disable-next-line @typescript-eslint/no-unused-vars - userContext?: Record + userContext?: Record, ): string | string[] | undefined { if (field.required && (value === undefined || (typeof value === "string" && value.trim() === ""))) { return `The "${field.label}" field is required`; @@ -289,26 +289,26 @@ export class ProgressiveProfilingService { }; isSectionCompleted = async function ( - this: ProgressiveProfilingService, + this: Implementation, session: SessionContainerInterface, section: FormSection, data: ProfileFormData, - userContext?: Record + userContext?: Record, ) { const valuesByFieldId = mapBy(data, "fieldId", (row) => row.value); return section.fields.every( - (field) => this.validateField(session, field, valuesByFieldId[field.id], userContext) === undefined + (field) => this.validateField(session, field, valuesByFieldId[field.id], userContext) === undefined, ); }; areAllSectionsCompleted = function ( - this: ProgressiveProfilingService, + this: Implementation, session: SessionContainerInterface, profileConfig?: UserMetadataConfig, - userContext?: Record + userContext?: Record, ) { return this.getSections(session, userContext).every( - (section) => profileConfig?.sectionCompleted?.[section.id] ?? false + (section) => profileConfig?.sectionCompleted?.[section.id] ?? false, ); }; } diff --git a/packages/progressive-profiling-nodejs/src/plugin.ts b/packages/progressive-profiling-nodejs/src/plugin.ts index de2afb6..9e14e36 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.ts @@ -11,12 +11,12 @@ import { } from "./types"; import { HANDLE_BASE_PATH, PLUGIN_ID, METADATA_KEY, PLUGIN_SDK_VERSION, DEFAULT_SECTIONS } from "./constants"; import { enableDebugLogs } from "./logger"; -import { ProgressiveProfilingService } from "./progressive-profiling-service"; +import { Implementation } from "./implementation"; export const init = createPluginInitFunction< SuperTokensPlugin, SuperTokensPluginProfileProgressiveProfilingConfig, - ProgressiveProfilingService, + Implementation, SuperTokensPluginProfileProgressiveProfilingNormalisedConfig >( (pluginConfig, implementation) => { @@ -62,7 +62,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -82,7 +82,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -104,7 +104,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -127,13 +127,13 @@ export const init = createPluginInitFunction< getGlobalClaimValidators: async function (input) { return [ ...(await originalImplementation.getGlobalClaimValidators(input)), - ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.validators.isTrue(), + Implementation.ProgressiveProfilingCompletedClaim.validators.isTrue(), ]; }, createNewSession: async (input) => { input.accessTokenPayload = { ...input.accessTokenPayload, - ...(await ProgressiveProfilingService.ProgressiveProfilingCompletedClaim.build( + ...(await Implementation.ProgressiveProfilingCompletedClaim.build( input.userId, input.recipeUserId, input.tenantId, @@ -158,7 +158,7 @@ export const init = createPluginInitFunction< }; }, - (config) => new ProgressiveProfilingService(config), + (config) => Implementation.init(config), (config) => { return { ...config, From b581221c7c5cff3842f0d08f0583c4f50afa5d5f Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 8 Sep 2025 14:04:15 +0300 Subject: [PATCH 28/46] export get set profile methods --- .../progressive-profiling-nodejs/src/index.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/index.ts b/packages/progressive-profiling-nodejs/src/index.ts index becd1e5..9f420e2 100644 --- a/packages/progressive-profiling-nodejs/src/index.ts +++ b/packages/progressive-profiling-nodejs/src/index.ts @@ -1,7 +1,22 @@ import { init } from "./plugin"; import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; +import { Implementation } from "./implementation"; +import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; +import { ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; export type { RegisterSections as RegisterSection } from "./types"; -export { init, PLUGIN_ID, PLUGIN_VERSION }; -export default { init, PLUGIN_ID, PLUGIN_VERSION }; +const getProfile = (session: SessionContainerInterface, userContext?: Record) => { + return Implementation.getInstanceOrThrow().getSectionValues(session, userContext); +}; + +const setProfile = ( + session: SessionContainerInterface, + profile: ProfileFormData, + userContext?: Record, +) => { + return Implementation.getInstanceOrThrow().setSectionValues(session, profile, userContext); +}; + +export { init, PLUGIN_ID, PLUGIN_VERSION, getProfile, setProfile }; +export default { init, PLUGIN_ID, PLUGIN_VERSION, getProfile, setProfile }; From 6d4ff0f1532edbc534b237cf59389441dafb583d Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 8 Sep 2025 14:04:40 +0300 Subject: [PATCH 29/46] cleanup --- .../progressive-profiling-form/progressive-profiling-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index 07ea06f..9330067 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -1,5 +1,5 @@ import { groupBy } from "@shared/js"; -import { Button, FormInput, FormFieldValue, Card, usePrettyAction, useToast } from "@shared/ui"; +import { Button, FormInput, FormFieldValue, Card, usePrettyAction } from "@shared/ui"; import { FormSection, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; import classNames from "classnames/bind"; import { useCallback, useEffect, useMemo, useState } from "react"; From 3711cfc5d79df9523df618f91c9b3d20fd4ccb63 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 10 Sep 2025 14:50:52 +0300 Subject: [PATCH 30/46] update exports and tests --- .../progressive-profiling-nodejs/src/index.ts | 25 +- .../src/plugin.test.ts | 219 ++++++++++++++++++ 2 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 packages/progressive-profiling-nodejs/src/plugin.test.ts diff --git a/packages/progressive-profiling-nodejs/src/index.ts b/packages/progressive-profiling-nodejs/src/index.ts index 9f420e2..a06bde2 100644 --- a/packages/progressive-profiling-nodejs/src/index.ts +++ b/packages/progressive-profiling-nodejs/src/index.ts @@ -3,14 +3,15 @@ import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; import { Implementation } from "./implementation"; import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; import { ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; +import { RegisterSections } from "./types"; export type { RegisterSections as RegisterSection } from "./types"; -const getProfile = (session: SessionContainerInterface, userContext?: Record) => { +const getSectionValues = (session: SessionContainerInterface, userContext?: Record) => { return Implementation.getInstanceOrThrow().getSectionValues(session, userContext); }; -const setProfile = ( +const setSectionValues = ( session: SessionContainerInterface, profile: ProfileFormData, userContext?: Record, @@ -18,5 +19,21 @@ const setProfile = ( return Implementation.getInstanceOrThrow().setSectionValues(session, profile, userContext); }; -export { init, PLUGIN_ID, PLUGIN_VERSION, getProfile, setProfile }; -export default { init, PLUGIN_ID, PLUGIN_VERSION, getProfile, setProfile }; +const registerSections = (payload: Parameters[0]) => { + return Implementation.getInstanceOrThrow().registerSections(payload); +}; + +const getSections = () => { + return Implementation.getInstanceOrThrow().getSections(); +}; + +export { init, PLUGIN_ID, PLUGIN_VERSION, getSectionValues, setSectionValues, registerSections, getSections }; +export default { + init, + PLUGIN_ID, + PLUGIN_VERSION, + getSectionValues, + setSectionValues, + registerSections, + getSections, +}; diff --git a/packages/progressive-profiling-nodejs/src/plugin.test.ts b/packages/progressive-profiling-nodejs/src/plugin.test.ts new file mode 100644 index 0000000..c86762c --- /dev/null +++ b/packages/progressive-profiling-nodejs/src/plugin.test.ts @@ -0,0 +1,219 @@ +import { init } from "./plugin"; +import SuperTokens from "supertokens-node"; +import SuperTokensRaw from "supertokens-node/lib/build/supertokens"; +import Session from "supertokens-node/recipe/session"; +import UserRoles from "supertokens-node/recipe/userroles"; +import SessionRaw from "supertokens-node/lib/build/recipe/session/recipe"; +import UserRolesRaw from "supertokens-node/lib/build/recipe/userroles/recipe"; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import { middleware, errorHandler } from "supertokens-node/framework/express"; +import { verifySession } from "supertokens-node/recipe/session/framework/express"; +import EmailPassword from "supertokens-node/recipe/emailpassword"; +import EmailPasswordRaw from "supertokens-node/lib/build/recipe/emailpassword/recipe"; +import { FormSection, SuperTokensPluginProfileProgressiveProfilingConfig } from "./types"; +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 Multitenancy from "supertokens-node/recipe/multitenancy"; +import { registerSections } from "./index"; +import crypto from "node:crypto"; +import { HANDLE_BASE_PATH } from "./constants"; +import { DEFAULT_BANNED_USER_ROLE } from "../../user-banning-nodejs/src/constants"; + +const testPORT = process.env.PORT || 3000; +const testEmail = "user@test.com"; +const testPW = "test"; + +describe("progressive-profiling-nodejs", () => { + describe("[API]", () => { + afterEach(() => { + resetST(); + }); + + beforeEach(() => { + resetST(); + }); + + it("should get the default configured sections", async () => { + const sections = [ + { + id: "test", + label: "Test", + fields: [ + { + id: "test", + label: "Test", + type: "text", + required: false, + defaultValue: "test", + description: "Test", + placeholder: "Test", + }, + ], + }, + ]; + const { user } = await setup({ + sections: sections as FormSection[], + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/sections`, { + method: "GET", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + }); + + expect(response.status).toBe(200); + + const result = await response.json(); + + expect(result.status).toBe("OK"); + expect(result.sections).toEqual(sections.map((section) => ({ ...section, completed: false }))); + }); + + it("should get the registered sections", async () => { + const { user } = await setup(); + + const sections = [ + { + id: "test", + label: "Test", + fields: [ + { + id: "test", + label: "Test", + type: "text", + required: false, + defaultValue: "test", + description: "Test", + placeholder: "Test", + }, + ], + }, + ]; + + registerSections({ + get: async () => [], + set: async () => {}, + registratorId: "test", + sections: sections as FormSection[], + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/sections`, { + method: "GET", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + }); + + expect(response.status).toBe(200); + + const result = await response.json(); + + expect(result.status).toBe("OK"); + expect(result.sections).toEqual(sections.map((section) => ({ ...section, completed: false }))); + }); + + it("should fail claim validation if the profile is not completed", async () => {}); + it("should allow api calls even if the claim is not valid", async () => {}); + it("should not allow api calls to get profile if session is not valid", async () => {}); + it("should not allow api calls to set profile if session is not valid", async () => {}); + it("should return the profile details for every sections and fields", async () => {}); + it("should set the profile details using the default registrator", async () => {}); + it("should set the profile details using a custom registrator", async () => {}); + it("should set the profile details partially", async () => {}); + it("should validate the profile details", async () => {}); + it("should check if the profile is completed", async () => {}); + }); +}); + +function resetST() { + SuperTokensRaw.reset(); + SessionRaw.reset(); + UserRolesRaw.reset(); + EmailPasswordRaw.reset(); + AccountLinkingRaw.reset(); + MultitenancyRaw.reset(); + UserMetadataRaw.reset(); +} + +async function setup(pluginConfig?: SuperTokensPluginProfileProgressiveProfilingConfig, appId?: string) { + 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; + } + const createAppResp = await fetch(`${coreBaseURL}/recipe/multitenancy/app/v2`, { + method: "PUT", + headers, + body: JSON.stringify({ + appId, + coreConfig: {}, + }), + }); + } + + 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: [Session.init({}), EmailPassword.init({})], + experimental: { + plugins: [init(pluginConfig)], + }, + }); + const app = express(); + // This exposes all the APIs from SuperTokens to the client. + 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)); + + let user; + if (isNewApp) { + const signupResponse = await EmailPassword.signUp("public", testEmail, testPW); + if (signupResponse.status !== "OK") { + console.log(signupResponse); + throw new Error("Failed to set up test user"); + } + user = signupResponse.user; + } else { + const userResponse = await SuperTokens.listUsersByAccountInfo("public", { + email: testEmail, + }); + user = userResponse[0]; + } + + return { + user, + appId, + }; +} From 9f349047e4cafd471886bf1853c159c8dedd1bbf Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 11 Sep 2025 16:01:42 +0300 Subject: [PATCH 31/46] fix testing config to work with supertokens-dev --- packages/progressive-profiling-nodejs/vitest.config.ts | 3 +++ packages/user-banning-nodejs/vitest.config.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/progressive-profiling-nodejs/vitest.config.ts b/packages/progressive-profiling-nodejs/vitest.config.ts index 826c9b2..ad798df 100644 --- a/packages/progressive-profiling-nodejs/vitest.config.ts +++ b/packages/progressive-profiling-nodejs/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + preserveSymlinks: true, + }, test: { globals: true, environment: "node", diff --git a/packages/user-banning-nodejs/vitest.config.ts b/packages/user-banning-nodejs/vitest.config.ts index 826c9b2..ad798df 100644 --- a/packages/user-banning-nodejs/vitest.config.ts +++ b/packages/user-banning-nodejs/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + preserveSymlinks: true, + }, test: { globals: true, environment: "node", From 3a1d7d82277d70b98ac5a69bfc237119996d04c7 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 11 Sep 2025 17:18:56 +0300 Subject: [PATCH 32/46] add support for plugin implementation reset --- shared/js/src/baseImplementation.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shared/js/src/baseImplementation.ts b/shared/js/src/baseImplementation.ts index cea0bce..7f66a1e 100644 --- a/shared/js/src/baseImplementation.ts +++ b/shared/js/src/baseImplementation.ts @@ -45,6 +45,10 @@ export abstract class BasePluginImplementation { return ServiceClass.instance; } + public static reset() { + this.instance = undefined; + } + /** * Constructor that takes the plugin configuration. * @param config - The plugin configuration From a385d722404bcaa8521f57639ca798dda1f44053 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 11 Sep 2025 17:19:07 +0300 Subject: [PATCH 33/46] add missing tests --- .../src/plugin.test.ts | 571 +++++++++++++++--- 1 file changed, 498 insertions(+), 73 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/plugin.test.ts b/packages/progressive-profiling-nodejs/src/plugin.test.ts index c86762c..09b6a95 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.test.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.test.ts @@ -1,60 +1,63 @@ -import { init } from "./plugin"; -import SuperTokens from "supertokens-node"; +import express from "express"; +import crypto from "node:crypto"; +import { describe, it, expect, afterEach, beforeEach } from "vitest"; + +import { FormSection, SuperTokensPluginProfileProgressiveProfilingConfig } from "./types"; +import { registerSections, init, getSectionValues, setSectionValues } from "./index"; +import { HANDLE_BASE_PATH } from "./constants"; + +import SuperTokens from "supertokens-node/lib/build/index"; +import Session from "supertokens-node/lib/build/recipe/session/index"; +import EmailPassword from "supertokens-node/lib/build/recipe/emailpassword/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 Session from "supertokens-node/recipe/session"; -import UserRoles from "supertokens-node/recipe/userroles"; import SessionRaw from "supertokens-node/lib/build/recipe/session/recipe"; import UserRolesRaw from "supertokens-node/lib/build/recipe/userroles/recipe"; -import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; -import express from "express"; -import { middleware, errorHandler } from "supertokens-node/framework/express"; -import { verifySession } from "supertokens-node/recipe/session/framework/express"; -import EmailPassword from "supertokens-node/recipe/emailpassword"; import EmailPasswordRaw from "supertokens-node/lib/build/recipe/emailpassword/recipe"; -import { FormSection, SuperTokensPluginProfileProgressiveProfilingConfig } from "./types"; 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 Multitenancy from "supertokens-node/recipe/multitenancy"; -import { registerSections } from "./index"; -import crypto from "node:crypto"; -import { HANDLE_BASE_PATH } from "./constants"; -import { DEFAULT_BANNED_USER_ROLE } from "../../user-banning-nodejs/src/constants"; +import { Implementation } from "./implementation"; const testPORT = process.env.PORT || 3000; const testEmail = "user@test.com"; const testPW = "test"; +const testSections = [ + { + id: "test", + label: "Test", + fields: [ + { + id: "test", + label: "Test", + type: "text", + required: false, + defaultValue: "test", + description: "Test", + placeholder: "Test", + }, + ], + }, +] as FormSection[]; describe("progressive-profiling-nodejs", () => { describe("[API]", () => { afterEach(() => { resetST(); + Implementation.reset(); }); - beforeEach(() => { resetST(); + Implementation.reset(); }); it("should get the default configured sections", async () => { - const sections = [ - { - id: "test", - label: "Test", - fields: [ - { - id: "test", - label: "Test", - type: "text", - required: false, - defaultValue: "test", - description: "Test", - placeholder: "Test", - }, - ], - }, - ]; const { user } = await setup({ - sections: sections as FormSection[], + sections: testSections, }); const session = await Session.createNewSessionWithoutRequestResponse( @@ -74,35 +77,17 @@ describe("progressive-profiling-nodejs", () => { const result = await response.json(); expect(result.status).toBe("OK"); - expect(result.sections).toEqual(sections.map((section) => ({ ...section, completed: false }))); + expect(result.sections).toEqual(testSections.map((section) => ({ ...section, completed: false }))); }); it("should get the registered sections", async () => { const { user } = await setup(); - const sections = [ - { - id: "test", - label: "Test", - fields: [ - { - id: "test", - label: "Test", - type: "text", - required: false, - defaultValue: "test", - description: "Test", - placeholder: "Test", - }, - ], - }, - ]; - registerSections({ get: async () => [], set: async () => {}, registratorId: "test", - sections: sections as FormSection[], + sections: testSections, }); const session = await Session.createNewSessionWithoutRequestResponse( @@ -122,33 +107,474 @@ describe("progressive-profiling-nodejs", () => { const result = await response.json(); expect(result.status).toBe("OK"); - expect(result.sections).toEqual(sections.map((section) => ({ ...section, completed: false }))); - }); - - it("should fail claim validation if the profile is not completed", async () => {}); - it("should allow api calls even if the claim is not valid", async () => {}); - it("should not allow api calls to get profile if session is not valid", async () => {}); - it("should not allow api calls to set profile if session is not valid", async () => {}); - it("should return the profile details for every sections and fields", async () => {}); - it("should set the profile details using the default registrator", async () => {}); - it("should set the profile details using a custom registrator", async () => {}); - it("should set the profile details partially", async () => {}); - it("should validate the profile details", async () => {}); - it("should check if the profile is completed", async () => {}); + expect(result.sections).toEqual(testSections.map((section) => ({ ...section, completed: false }))); + }); + + it("should fail claim validation if the profile is not completed", async () => { + const { user } = await setup({ + sections: testSections, + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + const claims = await Session.validateClaimsForSessionHandle(session.getHandle()); + expect(claims.status).toBe("OK"); + + // @ts-ignore + expect(claims.invalidClaims).toContainEqual({ + id: "supertokens-plugin-progressive-profiling-completed", + reason: { + actualValue: false, + expectedValue: true, + message: "wrong value", + }, + }); + }); + + it("should pass claim validation if the profile is completed", async () => { + const { user } = await setup({ + sections: testSections, + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + const claims = await Session.validateClaimsForSessionHandle(session.getHandle()); + expect(claims.status).toBe("OK"); + + // @ts-expect-error we'd need to do an if check on the status because of the discriminated union type + expect(claims.invalidClaims).toContainEqual({ + id: "supertokens-plugin-progressive-profiling-completed", + reason: { + actualValue: false, + expectedValue: true, + message: "wrong value", + }, + }); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + body: JSON.stringify({ + data: testSections + .map((section) => + section.fields.map((field) => ({ + sectionId: section.id, + fieldId: field.id, + value: "value", + })) + ) + .flat(), + }), + }); + expect(response.status).toBe(200); + + const validatedClaims = await Session.validateClaimsForSessionHandle(session.getHandle()); + expect(claims.status).toBe("OK"); + // @ts-expect-error we'd need to do an if check on the status because of the discriminated union type + expect(validatedClaims.invalidClaims).toHaveLength(0); + }); + + it("should not allow api calls if the claim is not valid", async () => { + const { user } = await setup({ + sections: testSections, + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + const response = await fetch(`http://localhost:${testPORT}/check-session`, { + method: "GET", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + }); + expect(response.status).toBe(403); + + const result = await response.json(); + expect(result.message).toBe("invalid claim"); + }); + + it("should allow api calls if the claim is valid", async () => { + const { user } = await setup({ + sections: testSections, + }); + + let session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + body: JSON.stringify({ + data: testSections + .map((section) => + section.fields.map((field) => ({ + sectionId: section.id, + fieldId: field.id, + value: "value", + })) + ) + .flat(), + }), + }); + + session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + const response = await fetch(`http://localhost:${testPORT}/check-session`, { + method: "GET", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + }); + expect(response.status).toBe(200); + }); + + it("should set the profile details using the default registrator", async () => { + const { user } = await setup({ + sections: testSections, + }); + + let session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + body: JSON.stringify({ + data: testSections + .map((section) => + section.fields.map((field) => ({ + sectionId: section.id, + fieldId: field.id, + value: "value", + })) + ) + .flat(), + }), + }); + + const sectionValues = await getSectionValues(session); + expect(sectionValues.status).toBe("OK"); + expect(sectionValues.data).toEqual( + testSections + .map((section) => + section.fields.map((field) => ({ fieldId: field.id, sectionId: section.id, value: "value" })) + ) + .flat() + ); + }); + + it("should set the profile details using a custom registrator", async () => { + const { user } = await setup({ + sections: testSections, + override: (oI) => ({ + ...oI, + getDefaultRegistrator: (...props) => ({ + ...oI.getDefaultRegistrator.apply(oI, props), + set: async () => {}, + }), + }), + }); + + let session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + body: JSON.stringify({ + data: testSections + .map((section) => + section.fields.map((field) => ({ + sectionId: section.id, + fieldId: field.id, + value: "value", + })) + ) + .flat(), + }), + }); + + const sectionValues = await getSectionValues(session); + expect(sectionValues.status).toBe("OK"); + expect(sectionValues.data).toEqual( + testSections + .map((section) => + section.fields.map((field) => ({ fieldId: field.id, sectionId: section.id, value: field.defaultValue })) + ) + .flat() + ); + }); + + it("should set the profile details partially", async () => { + const { user } = await setup({ + sections: [ + { + ...testSections[0], + fields: [ + ...testSections[0].fields, + { + id: "test2", + label: "Test2", + type: "text", + required: false, + description: "Test2", + placeholder: "Test2", + }, + ], + }, + ], + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + body: JSON.stringify({ + data: testSections + .map((section) => + section.fields.map((field) => ({ + sectionId: section.id, + fieldId: field.id, + value: "value", + })) + ) + .flat(), + }), + }); + + const sectionValues = await getSectionValues(session); + expect(sectionValues.status).toBe("OK"); + expect(sectionValues.data).toEqual([ + ...testSections + .map((section) => + section.fields.map((field) => ({ fieldId: field.id, sectionId: section.id, value: "value" })) + ) + .flat(), + { fieldId: "test2", sectionId: "test" }, + ]); + }); + + it("should return the profile details using the default registrator", async () => { + const { user } = await setup({ + sections: testSections, + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + await setSectionValues( + session, + testSections + .map((section) => + section.fields.map((field) => ({ + sectionId: section.id, + fieldId: field.id, + value: "value", + })) + ) + .flat() + ); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { + method: "GET", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + }); + expect(response.status).toBe(200); + + const result = await response.json(); + expect(result.status).toBe("OK"); + expect(result.data).toEqual( + testSections + .map((section) => + section.fields.map((field) => ({ fieldId: field.id, sectionId: section.id, value: "value" })) + ) + .flat() + ); + }); + + it("should return the profile details using a custom registrator", async () => { + const { user } = await setup({ + sections: testSections, + override: (oI) => ({ + ...oI, + getDefaultRegistrator: (...props) => ({ + ...oI.getDefaultRegistrator.apply(oI, props), + get: async () => [], + }), + }), + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + await setSectionValues( + session, + testSections + .map((section) => + section.fields.map((field) => ({ + sectionId: section.id, + fieldId: field.id, + value: "value", + })) + ) + .flat() + ); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { + method: "GET", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + }); + expect(response.status).toBe(200); + + const result = await response.json(); + expect(result.status).toBe("OK"); + expect(result.data).toEqual([]); + }); + + it("should validate the profile details using the default validator", async () => { + const { user } = await setup({ + sections: testSections.map((section) => ({ + ...section, + fields: section.fields.map((field) => ({ ...field, required: true, defaultValue: undefined })), + })), + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + body: JSON.stringify({ + data: testSections + .map((section) => + section.fields.map((field) => ({ + sectionId: section.id, + fieldId: field.id, + value: undefined, + })) + ) + .flat(), + }), + }); + + expect(response.status).toBe(400); + + const result = await response.json(); + expect(result).toEqual({ + status: "INVALID_FIELDS", + errors: testSections + .map((section) => + section.fields.map((field) => ({ id: field.id, error: `The "${field.label}" field is required` })) + ) + .flat(), + }); + }); + + it("should validate the profile details using a custom validator", async () => { + const { user } = await setup({ + sections: testSections, + override: (oI) => ({ + ...oI, + validateField: (session, field, value, userContext) => { + return "TestInvalid"; + }, + }), + }); + + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.getAccessToken()}`, + }, + body: JSON.stringify({ + data: testSections + .map((section) => + section.fields.map((field) => ({ + sectionId: section.id, + fieldId: field.id, + value: "value", + })) + ) + .flat(), + }), + }); + + expect(response.status).toBe(400); + + const result = await response.json(); + expect(result).toEqual({ + status: "INVALID_FIELDS", + errors: testSections + .map((section) => section.fields.map((field) => ({ id: field.id, error: "TestInvalid" }))) + .flat(), + }); + }); }); }); function resetST() { - SuperTokensRaw.reset(); + ProcessState.getInstance().reset(); SessionRaw.reset(); UserRolesRaw.reset(); EmailPasswordRaw.reset(); AccountLinkingRaw.reset(); MultitenancyRaw.reset(); UserMetadataRaw.reset(); + SuperTokensRaw.reset(); } -async function setup(pluginConfig?: SuperTokensPluginProfileProgressiveProfilingConfig, appId?: string) { +async function setup(pluginConfig?: Parameters[0]) { + let appId; let isNewApp = false; const coreBaseURL = process.env.CORE_BASE_URL || `http://localhost:3567`; if (appId === undefined) { @@ -160,11 +586,11 @@ async function setup(pluginConfig?: SuperTokensPluginProfileProgressiveProfiling if (process.env.CORE_API_KEY) { headers["api-key"] = process.env.CORE_API_KEY; } - const createAppResp = await fetch(`${coreBaseURL}/recipe/multitenancy/app/v2`, { + await fetch(`${coreBaseURL}/recipe/multitenancy/app/v2`, { method: "PUT", headers, body: JSON.stringify({ - appId, + appId: appId, coreConfig: {}, }), }); @@ -201,7 +627,6 @@ async function setup(pluginConfig?: SuperTokensPluginProfileProgressiveProfiling if (isNewApp) { const signupResponse = await EmailPassword.signUp("public", testEmail, testPW); if (signupResponse.status !== "OK") { - console.log(signupResponse); throw new Error("Failed to set up test user"); } user = signupResponse.user; @@ -214,6 +639,6 @@ async function setup(pluginConfig?: SuperTokensPluginProfileProgressiveProfiling return { user, - appId, + appId: appId, }; } From bd4a6d881ee06781d9387cc4dfda074686c6e022 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 11 Sep 2025 17:23:00 +0300 Subject: [PATCH 34/46] package lock updates --- package-lock.json | 337 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) diff --git a/package-lock.json b/package-lock.json index 59ad7da..dc48736 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2642,6 +2642,14 @@ "resolved": "packages/progressive-profiling-shared", "link": true }, + "node_modules/@supertokens-plugins/tenant-discovery-nodejs": { + "resolved": "packages/tenant-discovery-nodejs", + "link": true + }, + "node_modules/@supertokens-plugins/tenant-discovery-react": { + "resolved": "packages/tenant-discovery-react", + "link": true + }, "node_modules/@supertokens-plugins/user-banning-nodejs": { "resolved": "packages/user-banning-nodejs", "link": true @@ -15416,6 +15424,335 @@ "@shared/tsconfig": "*" } }, + "packages/tenant-discovery-nodejs": { + "name": "@supertokens-plugins/tenant-discovery-nodejs", + "version": "0.1.0", + "devDependencies": { + "@shared/eslint": "*", + "@shared/nodejs": "*", + "@shared/tsconfig": "*", + "@types/react": "^17.0.20", + "express": "^5.1.0", + "prettier": "3.6.2", + "pretty-quick": "^4.2.2", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "supertokens-node": ">=23.0.0" + } + }, + "packages/tenant-discovery-nodejs/node_modules/@types/react": { + "version": "17.0.88", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.88.tgz", + "integrity": "sha512-HEOvpzcFWkEcHq4EsTChnpimRc3Lz1/qzYRDFtobFp4obVa6QVjCDMjWmkgxgaTYttNvyjnldY8MUflGp5YiUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.0.2" + } + }, + "packages/tenant-discovery-react": { + "name": "@supertokens-plugins/tenant-discovery-react", + "version": "0.1.0", + "dependencies": { + "supertokens-js-override": "^0.0.4" + }, + "devDependencies": { + "@shared/eslint": "*", + "@shared/js": "*", + "@shared/react": "*", + "@shared/tsconfig": "*", + "@testing-library/jest-dom": "^6.1.0", + "@types/react": "^17.0.20", + "@vitejs/plugin-react": "^4.5.2", + "jsdom": "^26.1.0", + "prettier": "3.6.2", + "pretty-quick": "^4.2.2", + "rollup-plugin-peer-deps-external": "^2.2.4", + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vite-plugin-dts": "^4.5.4", + "vitest": "^1.3.1" + }, + "peerDependencies": { + "react": ">=18.3.1", + "react-dom": ">=18.3.1", + "supertokens-auth-react": ">=0.50.0", + "supertokens-web-js": ">=0.16.0" + } + }, + "packages/tenant-discovery-react/node_modules/@types/react": { + "version": "17.0.88", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.88.tgz", + "integrity": "sha512-HEOvpzcFWkEcHq4EsTChnpimRc3Lz1/qzYRDFtobFp4obVa6QVjCDMjWmkgxgaTYttNvyjnldY8MUflGp5YiUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.0.2" + } + }, + "packages/tenant-discovery-react/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "packages/tenant-discovery-react/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/tenant-discovery-react/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "packages/tenant-discovery-react/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/tenant-discovery-react/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/tenant-discovery-react/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/tenant-discovery-react/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/tenant-discovery-react/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/tenant-discovery-react/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/tenant-discovery-react/node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/tenant-discovery-react/node_modules/vitest/node_modules/vite": { + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "packages/user-banning-nodejs": { "name": "@supertokens-plugins/user-banning-nodejs", "version": "0.0.2-beta.2", From 930333e2585b02523aabe7a77c48711fe386a203 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 18 Sep 2025 19:02:59 +0300 Subject: [PATCH 35/46] pr fixes --- .../src/constants.ts | 2 +- .../src/implementation.ts | 211 +++++++++--------- .../progressive-profiling-nodejs/src/index.ts | 10 +- .../src/plugin.test.ts | 78 +++++-- .../src/plugin.ts | 30 +-- .../progressive-profiling-nodejs/src/types.ts | 4 +- shared/js/src/index.ts | 1 - 7 files changed, 199 insertions(+), 137 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/constants.ts b/packages/progressive-profiling-nodejs/src/constants.ts index cf59eb9..6cf3ed8 100644 --- a/packages/progressive-profiling-nodejs/src/constants.ts +++ b/packages/progressive-profiling-nodejs/src/constants.ts @@ -5,7 +5,7 @@ export const PLUGIN_VERSION = "0.0.1"; export const PLUGIN_SDK_VERSION = ["23.0.1", ">=23.0.1"]; export const METADATA_KEY = `${PLUGIN_ID}`; -export const METADATA_PROFILE_KEY = "st-default-profile"; // don't use plugin id, because we need to be able to have the same key in the profile plugin as well +export const METADATA_PROFILE_KEY = "st-profile"; // don't use plugin id, because we need to be able to have the same key in the profile plugin as well export const HANDLE_BASE_PATH = `/plugin/${PLUGIN_ID}`; diff --git a/packages/progressive-profiling-nodejs/src/implementation.ts b/packages/progressive-profiling-nodejs/src/implementation.ts index 60f1eca..3b75ac4 100644 --- a/packages/progressive-profiling-nodejs/src/implementation.ts +++ b/packages/progressive-profiling-nodejs/src/implementation.ts @@ -1,105 +1,125 @@ -import { - RegisterSections, - FormSection, - SuperTokensPluginProfileProgressiveProfilingNormalisedConfig, - UserMetadataConfig, -} from "./types"; +import { RegisterSections, FormSection, UserMetadataConfig } from "./types"; import { logDebugMessage } from "./logger"; import { FormField, FormFieldValue, ProfileFormData } from "@supertokens-plugins/progressive-profiling-shared"; import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; import { BooleanClaim } from "supertokens-node/recipe/session/claims"; import { PLUGIN_ID, METADATA_KEY, METADATA_PROFILE_KEY } from "./constants"; import { pluginUserMetadata } from "@shared/nodejs"; -import { BasePluginImplementation, groupBy, indexBy, mapBy } from "@shared/js"; +import { groupBy, indexBy, mapBy } from "@shared/js"; -export class Implementation extends BasePluginImplementation { - protected existingSections: (FormSection & { registratorId: string })[] = []; - protected existingRegistratorHandlers: Record[0], "set" | "get">> = {}; +export class Implementation { + static instance: Implementation | undefined; + + protected existingSections: (FormSection & { storageHandlerId: string })[] = []; + protected existingStorageHandlers: Record[0], "set" | "get">> = {}; protected metadata = pluginUserMetadata<{ profileConfig?: UserMetadataConfig }>(METADATA_KEY); static ProgressiveProfilingCompletedClaim: BooleanClaim; - constructor(pluginConfig: SuperTokensPluginProfileProgressiveProfilingNormalisedConfig) { - super(pluginConfig); + static init(): Implementation { + if (Implementation.instance) { + return Implementation.instance; + } + Implementation.instance = new Implementation(); + + 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() { Implementation.ProgressiveProfilingCompletedClaim = new BooleanClaim({ key: `${PLUGIN_ID}-completed`, - fetchValue: async (userId, recipeUserId, tenantId, currentPayload, userContext) => { - const userMetadata = await this.metadata.get(userId); - return this.areAllSectionsCompleted( - // can't pass session here because it's not available in the params or a way of getting it - undefined as unknown as SessionContainerInterface, - userMetadata?.profileConfig, - userContext, + fetchValue: async (userId) => { + const implementation = Implementation.getInstanceOrThrow(); + const userMetadata = await implementation.metadata.get(userId); + return implementation.existingSections.every( + (section) => userMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false, ); }, }); } - // todo make sure the implementation is the same as in the profile plugin (when it will be implement in the new repo - maybe part of a shared library or exported from the plugin itself ?) - getDefaultRegistrator = function ( + defaultStorageHandlerGetFields = async function ( this: Implementation, pluginFormFields: (Pick & { sectionId: string })[], - ) { + session: SessionContainerInterface, + userContext?: Record, + ): Promise { const metadata = pluginUserMetadata<{ profile: Record }>(METADATA_PROFILE_KEY); - return { - get: async (session: SessionContainerInterface, userContext?: Record) => { - const userMetadata = await metadata.get(session.getUserId(userContext), userContext); - const existingProfile = userMetadata?.profile || {}; + const userMetadata = await metadata.get(session.getUserId(userContext), userContext); + const existingProfile = userMetadata?.profile || {}; - const data = pluginFormFields.map((field) => ({ - sectionId: field.sectionId, - fieldId: field.id, - value: existingProfile[field.id] ?? field.defaultValue, - })); + const data = pluginFormFields.map((field) => ({ + sectionId: field.sectionId, + fieldId: field.id, + value: existingProfile[field.id] ?? field.defaultValue, + })); + + return data; + }; - return data; + defaultStorageHandlerSetFields = async function ( + this: Implementation, + pluginFormFields: (Pick & { sectionId: string })[], + formData: ProfileFormData, + session: SessionContainerInterface, + userContext?: Record, + ): Promise { + const metadata = pluginUserMetadata<{ profile: Record }>(METADATA_PROFILE_KEY); + + const userId = session.getUserId(userContext); + const userMetadata = await metadata.get(userId, userContext); + const existingProfile = userMetadata?.profile || {}; + + const profile = pluginFormFields.reduce( + (acc, field) => { + const newValue = formData.find((d) => d.fieldId === field.id)?.value; + const existingValue = existingProfile?.[field.id]; + return { + ...acc, + [field.id]: newValue ?? existingValue ?? field.defaultValue, + }; }, - set: async (formData: ProfileFormData, session: SessionContainerInterface, userContext?: Record) => { - const userId = session.getUserId(userContext); - const userMetadata = await metadata.get(userId, userContext); - const existingProfile = userMetadata?.profile || {}; - - const profile = pluginFormFields.reduce( - (acc, field) => { - const newValue = formData.find((d) => d.fieldId === field.id)?.value; - const existingValue = existingProfile?.[field.id]; - return { - ...acc, - [field.id]: newValue ?? existingValue ?? field.defaultValue, - }; - }, - { ...existingProfile }, - ); + { ...existingProfile }, + ); - await metadata.set( - userId, - { - profile: { - ...(userMetadata?.profile || {}), - ...profile, - }, - }, - userContext, - ); + await metadata.set( + userId, + { + profile: { + ...(userMetadata?.profile || {}), + ...profile, + }, }, - }; + userContext, + ); }; - registerSections: RegisterSections = function (this: Implementation, { registratorId, sections, set, get }) { + registerSections: RegisterSections = function (this: Implementation, { storageHandlerId, sections, set, get }) { const registrableSections = sections .filter((section) => { const existingSection = this.existingSections.find((s) => s.id === section.id); if (existingSection) { logDebugMessage( - `Profile plugin section with id "${section.id}" already registered by "${existingSection.registratorId}". Skipping...`, + `Profile plugin section with id "${section.id}" already registered by "${existingSection.storageHandlerId}". Skipping...`, ); return false; } - if (!registratorId) { - logDebugMessage(`Profile plugin section with id "${section.id}" has no registrator id. Skipping...`); + if (!storageHandlerId) { + logDebugMessage(`Profile plugin section with id "${section.id}" has no storage handler id. Skipping...`); return false; } @@ -107,14 +127,14 @@ export class Implementation extends BasePluginImplementation ({ ...section, - registratorId, + storageHandlerId, })); this.existingSections.push(...registrableSections); - this.existingRegistratorHandlers[registratorId] = { set, get }; + this.existingStorageHandlers[storageHandlerId] = { set, get }; }; - getSections = function ( + getAllSections = function ( this: Implementation, // eslint-disable-next-line @typescript-eslint/no-unused-vars session?: SessionContainerInterface, @@ -124,7 +144,7 @@ export class Implementation extends BasePluginImplementation, @@ -132,11 +152,11 @@ export class Implementation extends BasePluginImplementation ({ + const sections = this.getAllSections(session, userContext).map((section) => ({ id: section.id, label: section.label, description: section.description, - completed: userMetadata?.profileConfig?.sectionCompleted?.[section.id] ?? false, + completed: userMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false, fields: section.fields.map((field) => { return { id: field.id, @@ -162,11 +182,11 @@ export class Implementation extends BasePluginImplementation section.registratorId); + const sectionIdToStorageHandlerIdMap = mapBy(sections, "id", (section) => section.storageHandlerId); const dataBySectionId = groupBy(data, "sectionId"); - const dataByRegistratorId = groupBy(data, (row) => sectionIdToRegistratorIdMap[row.sectionId]); + const dataByStorageHandlerId = groupBy(data, (row) => sectionIdToStorageHandlerIdMap[row.sectionId]); // validate the data const validationErrors = data.reduce( @@ -199,20 +219,20 @@ export class Implementation extends BasePluginImplementation newUserMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false, + ); if (allSectionsCompleted) { await session.fetchAndSetClaim(Implementation.ProgressiveProfilingCompletedClaim, userContext); } @@ -256,17 +278,17 @@ export class Implementation extends BasePluginImplementation, ) { - const sections = this.getSections(session, userContext); - const sectionsByRegistratorId = indexBy(sections, "registratorId"); + const sections = this.getAllSections(session, userContext); + const sectionsByStorageHandlerId = indexBy(sections, "storageHandlerId"); const data: ProfileFormData = []; - for (const registratorId of Object.keys(sectionsByRegistratorId)) { - const registrator = this.existingRegistratorHandlers[registratorId]; - if (!registrator) { + for (const storageHandlerId of Object.keys(sectionsByStorageHandlerId)) { + const storageHandler = this.existingStorageHandlers[storageHandlerId]; + if (!storageHandler) { continue; } - const sectionData = await registrator.get(session, userContext); + const sectionData = await storageHandler.get(session, userContext); data.push(...sectionData); } @@ -300,15 +322,4 @@ export class Implementation extends BasePluginImplementation this.validateField(session, field, valuesByFieldId[field.id], userContext) === undefined, ); }; - - areAllSectionsCompleted = function ( - this: Implementation, - session: SessionContainerInterface, - profileConfig?: UserMetadataConfig, - userContext?: Record, - ) { - return this.getSections(session, userContext).every( - (section) => profileConfig?.sectionCompleted?.[section.id] ?? false, - ); - }; } diff --git a/packages/progressive-profiling-nodejs/src/index.ts b/packages/progressive-profiling-nodejs/src/index.ts index a06bde2..45b900d 100644 --- a/packages/progressive-profiling-nodejs/src/index.ts +++ b/packages/progressive-profiling-nodejs/src/index.ts @@ -14,7 +14,7 @@ const getSectionValues = (session: SessionContainerInterface, userContext?: Reco const setSectionValues = ( session: SessionContainerInterface, profile: ProfileFormData, - userContext?: Record, + userContext?: Record ) => { return Implementation.getInstanceOrThrow().setSectionValues(session, profile, userContext); }; @@ -23,11 +23,11 @@ const registerSections = (payload: Parameters[0]) => { return Implementation.getInstanceOrThrow().registerSections(payload); }; -const getSections = () => { - return Implementation.getInstanceOrThrow().getSections(); +const getAllSections = () => { + return Implementation.getInstanceOrThrow().getAllSections(); }; -export { init, PLUGIN_ID, PLUGIN_VERSION, getSectionValues, setSectionValues, registerSections, getSections }; +export { init, PLUGIN_ID, PLUGIN_VERSION, getSectionValues, setSectionValues, registerSections, getAllSections }; export default { init, PLUGIN_ID, @@ -35,5 +35,5 @@ export default { getSectionValues, setSectionValues, registerSections, - getSections, + getAllSections, }; diff --git a/packages/progressive-profiling-nodejs/src/plugin.test.ts b/packages/progressive-profiling-nodejs/src/plugin.test.ts index 09b6a95..a203e21 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.test.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.test.ts @@ -3,7 +3,7 @@ import crypto from "node:crypto"; import { describe, it, expect, afterEach, beforeEach } from "vitest"; import { FormSection, SuperTokensPluginProfileProgressiveProfilingConfig } from "./types"; -import { registerSections, init, getSectionValues, setSectionValues } from "./index"; +import { registerSections, init, getSectionValues, setSectionValues, getAllSections } from "./index"; import { HANDLE_BASE_PATH } from "./constants"; import SuperTokens from "supertokens-node/lib/build/index"; @@ -45,11 +45,12 @@ const testSections = [ ] as FormSection[]; describe("progressive-profiling-nodejs", () => { - describe("[API]", () => { + describe("API", () => { afterEach(() => { resetST(); Implementation.reset(); }); + beforeEach(() => { resetST(); Implementation.reset(); @@ -86,7 +87,7 @@ describe("progressive-profiling-nodejs", () => { registerSections({ get: async () => [], set: async () => {}, - registratorId: "test", + storageHandlerId: "test", sections: testSections, }); @@ -246,7 +247,7 @@ describe("progressive-profiling-nodejs", () => { expect(response.status).toBe(200); }); - it("should set the profile details using the default registrator", async () => { + it("should set the profile details using the default storage handler", async () => { const { user } = await setup({ sections: testSections, }); @@ -285,15 +286,12 @@ describe("progressive-profiling-nodejs", () => { ); }); - it("should set the profile details using a custom registrator", async () => { + it("should set the profile details using a custom storage handler", async () => { const { user } = await setup({ sections: testSections, override: (oI) => ({ ...oI, - getDefaultRegistrator: (...props) => ({ - ...oI.getDefaultRegistrator.apply(oI, props), - set: async () => {}, - }), + defaultStorageHandlerSetFields: () => Promise.resolve(), }), }); @@ -386,7 +384,7 @@ describe("progressive-profiling-nodejs", () => { ]); }); - it("should return the profile details using the default registrator", async () => { + it("should return the profile details using the default storage handler", async () => { const { user } = await setup({ sections: testSections, }); @@ -428,15 +426,12 @@ describe("progressive-profiling-nodejs", () => { ); }); - it("should return the profile details using a custom registrator", async () => { + it("should return the profile details using a custom storage handler", async () => { const { user } = await setup({ sections: testSections, override: (oI) => ({ ...oI, - getDefaultRegistrator: (...props) => ({ - ...oI.getDefaultRegistrator.apply(oI, props), - get: async () => [], - }), + defaultStorageHandlerGetFields: (...props) => Promise.resolve([]), }), }); @@ -560,6 +555,59 @@ describe("progressive-profiling-nodejs", () => { }); }); }); + + describe("exports", () => { + afterEach(() => { + resetST(); + Implementation.reset(); + }); + + beforeEach(() => { + resetST(); + Implementation.reset(); + }); + + describe("registerSections", () => { + it("should export the function", () => { + expect(getAllSections).toBeDefined(); + }); + + it("should return the sections", async () => { + const { user } = await setup({ + sections: testSections, + }); + + const sections = await getAllSections(); + console.log(sections); + console.log(testSections); + expect(sections).toEqual( + testSections.map((section) => ({ ...section, completed: undefined, storageHandlerId: "default" })) + ); + }); + + it("should return the correct sections when the getAllSections method is overridden", async () => { + const { user } = await setup({ + sections: testSections, + override: (oI) => ({ + ...oI, + getAllSections: () => + Promise.resolve( + testSections.map((section) => ({ + ...section, + completed: undefined, + storageHandlerId: "defaultOverride", + })) + ), + }), + }); + + const sections = await getAllSections(); + expect(sections).toEqual( + testSections.map((section) => ({ ...section, completed: undefined, storageHandlerId: "defaultOverride" })) + ); + }); + }); + }); }); function resetST() { diff --git a/packages/progressive-profiling-nodejs/src/plugin.ts b/packages/progressive-profiling-nodejs/src/plugin.ts index 9e14e36..f6498aa 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.ts @@ -20,6 +20,9 @@ export const init = createPluginInitFunction< SuperTokensPluginProfileProgressiveProfilingNormalisedConfig >( (pluginConfig, implementation) => { + // make sure the overrides are available to the cached + Implementation.instance = implementation; + const metadata = pluginUserMetadata<{ profileConfig?: UserMetadataConfig }>(METADATA_KEY); if (pluginConfig.sections.length > 0) { @@ -29,16 +32,17 @@ export const init = createPluginInitFunction< id: field.id, defaultValue: field.defaultValue, sectionId: section.id, - })), + })) ) .flat(); - const defaultRegistrator = implementation.getDefaultRegistrator(defaultFields); implementation.registerSections({ - registratorId: "default", + storageHandlerId: "default", sections: pluginConfig.sections, - set: defaultRegistrator.set, - get: defaultRegistrator.get, + set: (data, session, userContext) => + implementation.defaultStorageHandlerSetFields(defaultFields, data, session, userContext), + get: (session, userContext) => + implementation.defaultStorageHandlerGetFields(defaultFields, session, userContext), }); } @@ -62,7 +66,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key ); }, }, @@ -71,7 +75,7 @@ export const init = createPluginInitFunction< throw new Error("Session not found"); } - return implementation.getUserSections(session, userContext); + return implementation.getSessionUserSections(session, userContext); }), }, { @@ -82,7 +86,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key ); }, }, @@ -104,7 +108,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key, + (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key ); }, }, @@ -138,7 +142,7 @@ export const init = createPluginInitFunction< input.recipeUserId, input.tenantId, input.accessTokenPayload, - input.userContext, + input.userContext )), }; @@ -151,14 +155,14 @@ export const init = createPluginInitFunction< exports: { metadata, registerSections: implementation.registerSections, - getSections: implementation.getSections, + getSections: implementation.getAllSections, setSectionValues: implementation.setSectionValues, getSectionValues: implementation.getSectionValues, }, }; }, - (config) => Implementation.init(config), + () => Implementation.init(), (config) => { return { ...config, @@ -168,5 +172,5 @@ export const init = createPluginInitFunction< completed: undefined, // make sure the sections are not marked as completed by default })) ?? DEFAULT_SECTIONS, }; - }, + } ); diff --git a/packages/progressive-profiling-nodejs/src/types.ts b/packages/progressive-profiling-nodejs/src/types.ts index 4d4e188..7e4fb77 100644 --- a/packages/progressive-profiling-nodejs/src/types.ts +++ b/packages/progressive-profiling-nodejs/src/types.ts @@ -8,13 +8,13 @@ export type SuperTokensPluginProfileProgressiveProfilingNormalisedConfig = Required; export type UserMetadataConfig = { - sectionCompleted: Record; + sectionsCompleted: Record; }; export type FormSection = Omit; export type RegisterSections = (payload: { - registratorId: string; + storageHandlerId: string; sections: FormSection[]; set: (data: ProfileFormData, session: SessionContainerInterface, userContext?: Record) => Promise; get: (session: SessionContainerInterface, userContext?: Record) => Promise; diff --git a/shared/js/src/index.ts b/shared/js/src/index.ts index a66d997..a3d12ae 100644 --- a/shared/js/src/index.ts +++ b/shared/js/src/index.ts @@ -2,4 +2,3 @@ export * from "./createPluginInit"; export * from "./indexBy"; export * from "./groupBy"; export * from "./mapBy"; -export * from "./baseImplementation"; From 6c949faf25f9c25fd500aab6d8ca94811e5771a4 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 19 Sep 2025 19:41:34 +0300 Subject: [PATCH 36/46] removed unused code --- shared/js/src/baseImplementation.ts | 57 ----------------------------- 1 file changed, 57 deletions(-) delete mode 100644 shared/js/src/baseImplementation.ts diff --git a/shared/js/src/baseImplementation.ts b/shared/js/src/baseImplementation.ts deleted file mode 100644 index 7f66a1e..0000000 --- a/shared/js/src/baseImplementation.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Abstract base class for all SuperTokens plugin services that follow the singleton pattern. - * This ensures consistent initialization and instance management across all plugins. - * - * @template TConfig - The configuration type for the plugin - */ -export abstract class BasePluginImplementation { - /** - * The singleton instance of the service. Should be undefined until init() is called. - * This will be set by the init() method. - */ - protected static instance: any; - - /** - * Initialize the plugin service with the provided configuration. - * Creates and stores the singleton instance if not already initialized. - * @param config - The plugin configuration - * @param ServiceClass - The service class constructor (passed automatically when called on subclass) - * @returns The singleton instance - */ - public static init>(this: new (config: any) => T, config: any): T { - // Use the constructor reference to get the class - const ServiceClass = this as any; - - if (ServiceClass.instance) { - return ServiceClass.instance; - } - - ServiceClass.instance = new ServiceClass(config); - return ServiceClass.instance; - } - - /** - * Get the initialized instance or throw an error if not initialized. - * @throws Error if the instance has not been initialized - * @returns The singleton instance - */ - public static getInstanceOrThrow>(this: new (...args: any[]) => T): T { - const ServiceClass = this as any; - - if (!ServiceClass.instance) { - throw new Error(`${ServiceClass.name} instance not found. Make sure you have initialized the plugin.`); - } - - return ServiceClass.instance; - } - - public static reset() { - this.instance = undefined; - } - - /** - * Constructor that takes the plugin configuration. - * @param config - The plugin configuration - */ - constructor(protected config: TConfig) {} -} From 86aa512924f3beeb4929e8846b62552b8c3aba6e Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 19 Sep 2025 19:50:07 +0300 Subject: [PATCH 37/46] added changesets --- .changeset/brown-glasses-tan.md | 5 +++++ .changeset/chatty-brooms-tie.md | 5 +++++ .changeset/fine-bats-exist.md | 5 +++++ .changeset/honest-actors-enter.md | 5 +++++ .changeset/pink-candies-visit.md | 5 +++++ .changeset/twenty-showers-enter.md | 6 ++++++ .changeset/warm-radios-dance.md | 5 +++++ 7 files changed, 36 insertions(+) create mode 100644 .changeset/brown-glasses-tan.md create mode 100644 .changeset/chatty-brooms-tie.md create mode 100644 .changeset/fine-bats-exist.md create mode 100644 .changeset/honest-actors-enter.md create mode 100644 .changeset/pink-candies-visit.md create mode 100644 .changeset/twenty-showers-enter.md create mode 100644 .changeset/warm-radios-dance.md diff --git a/.changeset/brown-glasses-tan.md b/.changeset/brown-glasses-tan.md new file mode 100644 index 0000000..bbbc1f5 --- /dev/null +++ b/.changeset/brown-glasses-tan.md @@ -0,0 +1,5 @@ +--- +"@supertokens-plugins/user-banning-nodejs": patch +--- + +Fixed build script to work with symlinked dependencies diff --git a/.changeset/chatty-brooms-tie.md b/.changeset/chatty-brooms-tie.md new file mode 100644 index 0000000..bd1dde1 --- /dev/null +++ b/.changeset/chatty-brooms-tie.md @@ -0,0 +1,5 @@ +--- +"@shared/js": patch +--- + +Added utility functions such as indexBy, groupBy, mapBy diff --git a/.changeset/fine-bats-exist.md b/.changeset/fine-bats-exist.md new file mode 100644 index 0000000..819b3c2 --- /dev/null +++ b/.changeset/fine-bats-exist.md @@ -0,0 +1,5 @@ +--- +"@shared/react": patch +--- + +Fixed querier to correctly throw errors diff --git a/.changeset/honest-actors-enter.md b/.changeset/honest-actors-enter.md new file mode 100644 index 0000000..c54ca14 --- /dev/null +++ b/.changeset/honest-actors-enter.md @@ -0,0 +1,5 @@ +--- +"@shared/ui": minor +--- + +Added styling to image URL inut component diff --git a/.changeset/pink-candies-visit.md b/.changeset/pink-candies-visit.md new file mode 100644 index 0000000..e1fa014 --- /dev/null +++ b/.changeset/pink-candies-visit.md @@ -0,0 +1,5 @@ +--- +"@shared/ui": minor +--- + +Updated input types to be more simple and compatible diff --git a/.changeset/twenty-showers-enter.md b/.changeset/twenty-showers-enter.md new file mode 100644 index 0000000..d93d064 --- /dev/null +++ b/.changeset/twenty-showers-enter.md @@ -0,0 +1,6 @@ +--- +"@supertokens-plugins/progressive-profiling-nodejs": patch +"@supertokens-plugins/progressive-profiling-react": patch +--- + +Added progressive profiling plugin with support for registering sections from third parties diff --git a/.changeset/warm-radios-dance.md b/.changeset/warm-radios-dance.md new file mode 100644 index 0000000..d459c52 --- /dev/null +++ b/.changeset/warm-radios-dance.md @@ -0,0 +1,5 @@ +--- +"@shared/ui": patch +--- + +Improved error parsing in usePrettyAction hook From 0bf591a2a937ce18635ba21992c9be7911882769 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 19 Sep 2025 20:03:45 +0300 Subject: [PATCH 38/46] merge fixes --- package-lock.json | 252 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 251 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index f823d1b..567a55e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@shared/js": "*", "@shared/nodejs": "*", "@shared/react": "*", + "@supertokens-plugins/progressive-profiling-shared": "*", "tsup": "^8.5.0" }, "devDependencies": { @@ -26,6 +27,7 @@ "rollup-plugin-peer-deps-external": "^2.2.4", "turbo": "^2.5.5", "vite": "^6.3.5", + "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-dts": "^4.5.4" }, "engines": { @@ -2694,6 +2696,18 @@ "resolved": "packages/opentelemetry-nodejs", "link": true }, + "node_modules/@supertokens-plugins/progressive-profiling-nodejs": { + "resolved": "packages/progressive-profiling-nodejs", + "link": true + }, + "node_modules/@supertokens-plugins/progressive-profiling-react": { + "resolved": "packages/progressive-profiling-react", + "link": true + }, + "node_modules/@supertokens-plugins/progressive-profiling-shared": { + "resolved": "packages/progressive-profiling-shared", + "link": true + }, "node_modules/@supertokens-plugins/tenant-discovery-nodejs": { "resolved": "packages/tenant-discovery-nodejs", "link": true @@ -3304,6 +3318,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/through": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", @@ -5554,6 +5575,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -10336,6 +10367,17 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -13259,6 +13301,16 @@ } } }, + "node_modules/vite-plugin-css-injected-by-js": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/vite-plugin-css-injected-by-js/-/vite-plugin-css-injected-by-js-3.5.2.tgz", + "integrity": "sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": ">2.0.0-0" + } + }, "node_modules/vite-plugin-dts": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz", @@ -15720,7 +15772,7 @@ }, "packages/opentelemetry-nodejs": { "name": "@supertokens-plugins/opentelemetry-nodejs", - "version": "0.1.0", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.9.0", @@ -16560,6 +16612,204 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/progressive-profiling-nodejs": { + "name": "@supertokens-plugins/progressive-profiling-nodejs", + "version": "0.0.2-beta.2", + "dependencies": { + "@supertokens-plugins/progressive-profiling-shared": "*" + }, + "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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-react": { + "name": "@supertokens-plugins/progressive-profiling-react", + "version": "0.0.2-beta.2", + "dependencies": { + "@shared/js": "*", + "@shared/react": "*", + "@shared/ui": "*", + "@supertokens-plugins/progressive-profiling-shared": "*", + "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" + }, + "peerDependencies": { + "react": ">=18.3.1", + "react-dom": ">=18.3.1", + "supertokens-auth-react": ">=0.50.0" + } + }, + "packages/progressive-profiling-react/node_modules/@types/react": { + "version": "17.0.88", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.88.tgz", + "integrity": "sha512-HEOvpzcFWkEcHq4EsTChnpimRc3Lz1/qzYRDFtobFp4obVa6QVjCDMjWmkgxgaTYttNvyjnldY8MUflGp5YiUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.0.2" + } + }, + "packages/progressive-profiling-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-shared": { + "name": "@supertokens-plugins/progressive-profiling-shared", + "version": "0.0.1", + "devDependencies": { + "@shared/eslint": "*", + "@shared/tsconfig": "*" + } + }, "packages/tenant-discovery-nodejs": { "name": "@supertokens-plugins/tenant-discovery-nodejs", "version": "0.1.0", From 2f1a756dd15b6038f8c4c641d6ea7da526907888 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 22 Sep 2025 16:07:40 +0300 Subject: [PATCH 39/46] merge fixes --- package-lock.json | 231 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4073378..7c7be47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2951,6 +2951,18 @@ "resolved": "packages/profile-base-react", "link": true }, + "node_modules/@supertokens-plugins/progressive-profiling-nodejs": { + "resolved": "packages/progressive-profiling-nodejs", + "link": true + }, + "node_modules/@supertokens-plugins/progressive-profiling-react": { + "resolved": "packages/progressive-profiling-react", + "link": true + }, + "node_modules/@supertokens-plugins/progressive-profiling-shared": { + "resolved": "packages/progressive-profiling-shared", + "link": true + }, "node_modules/@supertokens-plugins/storybook": { "resolved": "packages/storybook", "link": true @@ -5969,6 +5981,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -10849,6 +10871,17 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -17450,6 +17483,204 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "packages/progressive-profiling-nodejs": { + "name": "@supertokens-plugins/progressive-profiling-nodejs", + "version": "0.0.2-beta.2", + "dependencies": { + "@supertokens-plugins/progressive-profiling-shared": "*" + }, + "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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-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/progressive-profiling-react": { + "name": "@supertokens-plugins/progressive-profiling-react", + "version": "0.0.2-beta.2", + "dependencies": { + "@shared/js": "*", + "@shared/react": "*", + "@shared/ui": "*", + "@supertokens-plugins/progressive-profiling-shared": "*", + "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" + }, + "peerDependencies": { + "react": ">=18.3.1", + "react-dom": ">=18.3.1", + "supertokens-auth-react": ">=0.50.0" + } + }, + "packages/progressive-profiling-react/node_modules/@types/react": { + "version": "17.0.88", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.88.tgz", + "integrity": "sha512-HEOvpzcFWkEcHq4EsTChnpimRc3Lz1/qzYRDFtobFp4obVa6QVjCDMjWmkgxgaTYttNvyjnldY8MUflGp5YiUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.0.2" + } + }, + "packages/progressive-profiling-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-shared": { + "name": "@supertokens-plugins/progressive-profiling-shared", + "version": "0.0.1", + "devDependencies": { + "@shared/eslint": "*", + "@shared/tsconfig": "*" + } + }, "packages/storybook": { "name": "@supertokens-plugins/storybook", "version": "0.0.0", From 534aa837b5844c3cc56f979883470e4742759beb Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 24 Sep 2025 09:50:01 +0300 Subject: [PATCH 40/46] make start and end sections configurable --- .../progressive-profiling-form.module.css | 2 +- .../progressive-profiling-form.tsx | 100 +++++++++++------- .../src/constants.ts | 2 + .../progressive-profiling-react/src/types.ts | 2 + 4 files changed, 65 insertions(+), 41 deletions(-) diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css index bfb93f3..7355fa7 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.module.css @@ -7,7 +7,7 @@ justify-content: space-evenly; flex-direction: row; width: 100%; - margin-bottom: var(--plugin-spacing-2xl); + margin-bottom: var(--wa-space-l); } .progressive-profiling-form-bullets .progressive-profiling-form-bullet { diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index 9330067..040c4ef 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -38,28 +38,38 @@ export const ProgressiveProfilingForm = ({ loadSections, ...props }: ProgressiveProfilingFormProps) => { - const { t } = usePluginContext(); + const { t, pluginConfig } = usePluginContext(); const [fieldErrors, setFieldErrors] = useState>({}); const sections = useMemo(() => { return [ - { - id: "profile-start", - label: t("PL_PP_SECTION_PROFILE_START_LABEL"), - description: t("PL_PP_SECTION_PROFILE_START_DESCRIPTION", { steps: (formSections.length + 2).toString() }), - completed: false, - fields: [], - }, + ...(pluginConfig.showStartSection + ? [ + { + id: "profile-start", + label: t("PL_PP_SECTION_PROFILE_START_LABEL"), + description: t("PL_PP_SECTION_PROFILE_START_DESCRIPTION", { + steps: (formSections.length + 2).toString(), + }), + completed: false, + fields: [], + }, + ] + : []), ...formSections, - { - id: "profile-end", - label: t("PL_PP_SECTION_PROFILE_END_LABEL"), - description: t("PL_PP_SECTION_PROFILE_END_DESCRIPTION"), - completed: false, - fields: [], - }, + ...(pluginConfig.showEndSection + ? [ + { + id: "profile-end", + label: t("PL_PP_SECTION_PROFILE_END_LABEL"), + description: t("PL_PP_SECTION_PROFILE_END_DESCRIPTION"), + completed: false, + fields: [], + }, + ] + : []), ]; - }, [formSections]); + }, [formSections, pluginConfig.showStartSection, pluginConfig.showEndSection]); const startingSectionIndex = useMemo(() => { const completedSectionIndexes = formSections @@ -73,8 +83,8 @@ export const ProgressiveProfilingForm = ({ // the index of the first not completed section (the user hasn't completed all the sections) const nextFormSectionIndex = completedSectionIndexes[completedSectionIndexes.length - 1]! + 1; - return nextFormSectionIndex + 1; // account for the start section - }, [formSections]); + return nextFormSectionIndex + (pluginConfig.showStartSection ? 1 : 0); // account for the start section + }, [formSections, pluginConfig.showStartSection]); const [activeSectionIndex, setActiveSectionIndex] = useState(startingSectionIndex); const [profileDetails, setProfileDetails] = useState>({}); @@ -100,7 +110,7 @@ export const ProgressiveProfilingForm = ({ const isSectionEnabled = useCallback( (sectionIndex: number) => { // first section is always enabled - if (sectionIndex === 0) { + if (sectionIndex === 0 && pluginConfig.showStartSection) { return true; } @@ -110,14 +120,14 @@ export const ProgressiveProfilingForm = ({ } // last section is enabled if all form sections are completed - if (sectionIndex === sections.length - 1) { + if (sectionIndex === sections.length - 1 && pluginConfig.showEndSection) { return formSections.every((section) => section.completed); } // other sections are enabled if they are completed return sections[sectionIndex]?.completed ?? false; }, - [sections, activeSectionIndex, formSections], + [sections, activeSectionIndex, formSections, pluginConfig], ); const handleSubmit = usePrettyAction(async () => { @@ -127,12 +137,30 @@ export const ProgressiveProfilingForm = ({ return; } + if (currentSection.id !== "profile-end" && currentSection.id !== "profile-start") { + // only send the current section fields + const sectionData = currentSection.fields.map((field) => { + return { sectionId: currentSection.id, fieldId: field.id, value: profileDetails[field.id] }; + }); + const result = await onSubmit(sectionData); + if (result.status === "INVALID_FIELDS") { + setFieldErrors(groupBy(result.errors, "id")); + throw new Error("Some fields are invalid"); + } else if (result.status === "OK") { + moveToSection(activeSectionIndex + 1); + // load the sections to get the updated section states (it's fine to be deferred) + loadSections(); + } else { + throw new Error("Could not save the details"); + } + } + if (currentSection.id === "profile-start") { moveToSection(activeSectionIndex + 1); return; } - if (currentSection.id === "profile-end") { + if (currentSection.id === "profile-end" || isLastSection) { const isComplete = formSections.every((section) => section.completed); if (isComplete) { const data: ProfileFormData = Object.entries(profileDetails).map(([key, value]) => { @@ -149,24 +177,16 @@ export const ProgressiveProfilingForm = ({ throw new Error("All sections must be completed to submit the form"); } } - - // only send the current section fields - const sectionData = currentSection.fields.map((field) => { - return { sectionId: currentSection.id, fieldId: field.id, value: profileDetails[field.id] }; - }); - - const result = await onSubmit(sectionData); - if (result.status === "INVALID_FIELDS") { - setFieldErrors(groupBy(result.errors, "id")); - throw new Error("Some fields are invalid"); - } else if (result.status === "OK") { - moveToSection(activeSectionIndex + 1); - // load the sections to get the updated section states (it's fine to be deferred) - loadSections(); - } else { - throw new Error("Could not save the details"); - } - }, [onSuccess, moveToSection, activeSectionIndex, profileDetails, currentSection]); + }, [ + onSuccess, + onSubmit, + loadSections, + moveToSection, + activeSectionIndex, + profileDetails, + currentSection, + isLastSection, + ]); const handleInputChange = useCallback((field: string, value: any) => { setProfileDetails((prev) => ({ diff --git a/packages/progressive-profiling-react/src/constants.ts b/packages/progressive-profiling-react/src/constants.ts index e8b7d45..fc3d17e 100644 --- a/packages/progressive-profiling-react/src/constants.ts +++ b/packages/progressive-profiling-react/src/constants.ts @@ -42,3 +42,5 @@ export const DEFAULT_SETUP_PAGE_PATH = "/user/setup"; export const DEFAULT_ON_SUCCESS = async () => { window.location.href = "/"; }; +export const DEFAULT_SHOW_START_SECTION = true; +export const DEFAULT_SHOW_END_SECTION = true; diff --git a/packages/progressive-profiling-react/src/types.ts b/packages/progressive-profiling-react/src/types.ts index 6904418..f5c5e3c 100644 --- a/packages/progressive-profiling-react/src/types.ts +++ b/packages/progressive-profiling-react/src/types.ts @@ -6,6 +6,8 @@ import { defaultTranslationsProgressiveProfiling } from "./translations"; export type SuperTokensPluginProfileProgressiveProfilingConfig = { setupPagePath?: string; requireSetup?: boolean; + showStartSection?: boolean; + showEndSection?: boolean; onSuccess: (data: ProfileFormData) => Promise; }; From a00598eb8aaaebe2b85d7afcab10bbd949b954b9 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 24 Sep 2025 09:59:16 +0300 Subject: [PATCH 41/46] merge fixes --- .../form-input/form-input.module.css | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/shared/ui/src/components/form-input/form-input.module.css b/shared/ui/src/components/form-input/form-input.module.css index 60dc2c2..f396ff2 100644 --- a/shared/ui/src/components/form-input/form-input.module.css +++ b/shared/ui/src/components/form-input/form-input.module.css @@ -22,22 +22,4 @@ display: block; margin: 0 auto; } - - .st-image-url-input-preview { - margin-top: 8px; - border: 1px solid #ddd; - border-radius: 4px; - padding: 8px; - background-color: #f9f9f9; - } - - .st-image-url-input-preview-img { - max-width: 200px; - max-height: 200px; - width: auto; - height: auto; - border-radius: 4px; - display: block; - margin: 0 auto; - } } From c86f1a3fe6bb5b53a79468305629207262c37c44 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 24 Sep 2025 16:19:33 +0300 Subject: [PATCH 42/46] changelog --- .changeset/brown-glasses-tan.md | 5 ----- .changeset/calm-wings-report.md | 5 ----- .changeset/chatty-brooms-tie.md | 5 ----- .changeset/fine-bats-exist.md | 5 ----- .changeset/honest-actors-enter.md | 5 ----- .changeset/pink-candies-visit.md | 5 ----- .changeset/twenty-showers-enter.md | 6 ------ .changeset/warm-radios-dance.md | 5 ----- packages/profile-base-react/CHANGELOG.md | 14 ++++++++++++++ packages/profile-base-react/package.json | 2 +- packages/progressive-profiling-nodejs/CHANGELOG.md | 6 ++++++ packages/progressive-profiling-nodejs/package.json | 4 ++-- packages/progressive-profiling-react/CHANGELOG.md | 14 ++++++++++++++ packages/progressive-profiling-react/package.json | 2 +- packages/user-banning-nodejs/CHANGELOG.md | 6 ++++++ packages/user-banning-nodejs/package.json | 2 +- shared/js/CHANGELOG.md | 7 +++++++ shared/js/package.json | 2 +- shared/react/CHANGELOG.md | 7 +++++++ shared/react/package.json | 2 +- shared/ui/CHANGELOG.md | 12 ++++++++++++ shared/ui/package.json | 2 +- 22 files changed, 74 insertions(+), 49 deletions(-) delete mode 100644 .changeset/brown-glasses-tan.md delete mode 100644 .changeset/calm-wings-report.md delete mode 100644 .changeset/chatty-brooms-tie.md delete mode 100644 .changeset/fine-bats-exist.md delete mode 100644 .changeset/honest-actors-enter.md delete mode 100644 .changeset/pink-candies-visit.md delete mode 100644 .changeset/twenty-showers-enter.md delete mode 100644 .changeset/warm-radios-dance.md create mode 100644 shared/js/CHANGELOG.md create mode 100644 shared/react/CHANGELOG.md create mode 100644 shared/ui/CHANGELOG.md diff --git a/.changeset/brown-glasses-tan.md b/.changeset/brown-glasses-tan.md deleted file mode 100644 index bbbc1f5..0000000 --- a/.changeset/brown-glasses-tan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@supertokens-plugins/user-banning-nodejs": patch ---- - -Fixed build script to work with symlinked dependencies diff --git a/.changeset/calm-wings-report.md b/.changeset/calm-wings-report.md deleted file mode 100644 index e641eea..0000000 --- a/.changeset/calm-wings-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@supertokens-plugins/profile-base-react": patch ---- - -Updated profile base plugin with correct styling according to design system diff --git a/.changeset/chatty-brooms-tie.md b/.changeset/chatty-brooms-tie.md deleted file mode 100644 index bd1dde1..0000000 --- a/.changeset/chatty-brooms-tie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@shared/js": patch ---- - -Added utility functions such as indexBy, groupBy, mapBy diff --git a/.changeset/fine-bats-exist.md b/.changeset/fine-bats-exist.md deleted file mode 100644 index 819b3c2..0000000 --- a/.changeset/fine-bats-exist.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@shared/react": patch ---- - -Fixed querier to correctly throw errors diff --git a/.changeset/honest-actors-enter.md b/.changeset/honest-actors-enter.md deleted file mode 100644 index c54ca14..0000000 --- a/.changeset/honest-actors-enter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@shared/ui": minor ---- - -Added styling to image URL inut component diff --git a/.changeset/pink-candies-visit.md b/.changeset/pink-candies-visit.md deleted file mode 100644 index e1fa014..0000000 --- a/.changeset/pink-candies-visit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@shared/ui": minor ---- - -Updated input types to be more simple and compatible diff --git a/.changeset/twenty-showers-enter.md b/.changeset/twenty-showers-enter.md deleted file mode 100644 index d93d064..0000000 --- a/.changeset/twenty-showers-enter.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@supertokens-plugins/progressive-profiling-nodejs": patch -"@supertokens-plugins/progressive-profiling-react": patch ---- - -Added progressive profiling plugin with support for registering sections from third parties diff --git a/.changeset/warm-radios-dance.md b/.changeset/warm-radios-dance.md deleted file mode 100644 index d459c52..0000000 --- a/.changeset/warm-radios-dance.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@shared/ui": patch ---- - -Improved error parsing in usePrettyAction hook diff --git a/packages/profile-base-react/CHANGELOG.md b/packages/profile-base-react/CHANGELOG.md index 9a4357d..505c3f4 100644 --- a/packages/profile-base-react/CHANGELOG.md +++ b/packages/profile-base-react/CHANGELOG.md @@ -1,5 +1,19 @@ # @supertokens-plugins/profile-base-react +## 0.0.1 + +### Patch Changes + +- 265dd16: Updated profile base plugin with correct styling according to design system +- Updated dependencies [86aa512] +- Updated dependencies [86aa512] +- Updated dependencies [86aa512] +- Updated dependencies [86aa512] +- Updated dependencies [86aa512] + - @shared/js@0.0.2 + - @shared/react@0.0.2 + - @shared/ui@0.1.0 + ## 0.1.0-beta.1 ### Minor Changes diff --git a/packages/profile-base-react/package.json b/packages/profile-base-react/package.json index 3ea0217..e71dafe 100644 --- a/packages/profile-base-react/package.json +++ b/packages/profile-base-react/package.json @@ -1,6 +1,6 @@ { "name": "@supertokens-plugins/profile-base-react", - "version": "0.0.1-beta.1", + "version": "0.0.1", "description": "Profile Base Plugin for SuperTokens", "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/profile-base-react/README.md", "repository": { diff --git a/packages/progressive-profiling-nodejs/CHANGELOG.md b/packages/progressive-profiling-nodejs/CHANGELOG.md index 8c62368..4473b88 100644 --- a/packages/progressive-profiling-nodejs/CHANGELOG.md +++ b/packages/progressive-profiling-nodejs/CHANGELOG.md @@ -1 +1,7 @@ # @supertokens-plugins/progressive-profiling-nodejs + +## 0.0.2 + +### Patch Changes + +- 86aa512: Added progressive profiling plugin with support for registering sections from third parties diff --git a/packages/progressive-profiling-nodejs/package.json b/packages/progressive-profiling-nodejs/package.json index 0d6b49c..5a14399 100644 --- a/packages/progressive-profiling-nodejs/package.json +++ b/packages/progressive-profiling-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "@supertokens-plugins/progressive-profiling-nodejs", - "version": "0.0.2-beta.2", + "version": "0.0.2", "description": "Progressive Profiling Plugin for SuperTokens", "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/progressive-profiling-nodejs/README.md", "repository": { @@ -55,4 +55,4 @@ "default": "dist/index.js" } } -} +} \ No newline at end of file diff --git a/packages/progressive-profiling-react/CHANGELOG.md b/packages/progressive-profiling-react/CHANGELOG.md index 9fe21ab..5f71314 100644 --- a/packages/progressive-profiling-react/CHANGELOG.md +++ b/packages/progressive-profiling-react/CHANGELOG.md @@ -1 +1,15 @@ # @supertokens-plugins/progressive-profiling-react + +## 0.0.2 + +### Patch Changes + +- 86aa512: Added progressive profiling plugin with support for registering sections from third parties +- Updated dependencies [86aa512] +- Updated dependencies [86aa512] +- Updated dependencies [86aa512] +- Updated dependencies [86aa512] +- Updated dependencies [86aa512] + - @shared/js@0.0.2 + - @shared/react@0.0.2 + - @shared/ui@0.1.0 diff --git a/packages/progressive-profiling-react/package.json b/packages/progressive-profiling-react/package.json index dedb9f1..ea0c547 100644 --- a/packages/progressive-profiling-react/package.json +++ b/packages/progressive-profiling-react/package.json @@ -1,6 +1,6 @@ { "name": "@supertokens-plugins/progressive-profiling-react", - "version": "0.0.2-beta.2", + "version": "0.0.2", "description": "Progressive Profiling Plugin for SuperTokens", "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/progressive-profiling-react/README.md", "repository": { diff --git a/packages/user-banning-nodejs/CHANGELOG.md b/packages/user-banning-nodejs/CHANGELOG.md index f83a6c2..5cf3a04 100644 --- a/packages/user-banning-nodejs/CHANGELOG.md +++ b/packages/user-banning-nodejs/CHANGELOG.md @@ -1,5 +1,11 @@ # @supertokens-plugins/user-banning-nodejs +## 0.0.2 + +### Patch Changes + +- 86aa512: Fixed build script to work with symlinked dependencies + ## 0.0.2-beta.2 ### Patch Changes diff --git a/packages/user-banning-nodejs/package.json b/packages/user-banning-nodejs/package.json index b85ad6a..6246832 100644 --- a/packages/user-banning-nodejs/package.json +++ b/packages/user-banning-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "@supertokens-plugins/user-banning-nodejs", - "version": "0.0.2-beta.2", + "version": "0.0.2", "description": "User Banning Plugin for SuperTokens", "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/user-banning-nodejs/README.md", "repository": { diff --git a/shared/js/CHANGELOG.md b/shared/js/CHANGELOG.md new file mode 100644 index 0000000..18a677a --- /dev/null +++ b/shared/js/CHANGELOG.md @@ -0,0 +1,7 @@ +# @shared/js + +## 0.0.2 + +### Patch Changes + +- 86aa512: Added utility functions such as indexBy, groupBy, mapBy diff --git a/shared/js/package.json b/shared/js/package.json index 44f523d..d04b2ba 100644 --- a/shared/js/package.json +++ b/shared/js/package.json @@ -1,6 +1,6 @@ { "name": "@shared/js", - "version": "0.0.1", + "version": "0.0.2", "private": true, "exports": { ".": "./src/index.ts" diff --git a/shared/react/CHANGELOG.md b/shared/react/CHANGELOG.md new file mode 100644 index 0000000..869435a --- /dev/null +++ b/shared/react/CHANGELOG.md @@ -0,0 +1,7 @@ +# @shared/react + +## 0.0.2 + +### Patch Changes + +- 86aa512: Fixed querier to correctly throw errors diff --git a/shared/react/package.json b/shared/react/package.json index fc77242..a07e198 100644 --- a/shared/react/package.json +++ b/shared/react/package.json @@ -1,6 +1,6 @@ { "name": "@shared/react", - "version": "0.0.1", + "version": "0.0.2", "private": true, "exports": { ".": "./src/index.ts" diff --git a/shared/ui/CHANGELOG.md b/shared/ui/CHANGELOG.md new file mode 100644 index 0000000..a812f2a --- /dev/null +++ b/shared/ui/CHANGELOG.md @@ -0,0 +1,12 @@ +# @shared/ui + +## 0.1.0 + +### Minor Changes + +- 86aa512: Added styling to image URL inut component +- 86aa512: Updated input types to be more simple and compatible + +### Patch Changes + +- 86aa512: Improved error parsing in usePrettyAction hook diff --git a/shared/ui/package.json b/shared/ui/package.json index 0b6b893..38f1c1f 100644 --- a/shared/ui/package.json +++ b/shared/ui/package.json @@ -1,6 +1,6 @@ { "name": "@shared/ui", - "version": "0.0.1", + "version": "0.1.0", "private": true, "exports": { ".": "./src/index.ts" From b507f9eebf3a51c66438d5bca39e82fa7d0a15be Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 24 Sep 2025 17:31:31 +0300 Subject: [PATCH 43/46] update implementation methods to only use objects as inputs --- .../src/implementation.ts | 151 +++++++++++------- .../progressive-profiling-nodejs/src/index.ts | 20 +-- .../src/plugin.test.ts | 54 ++++--- .../src/plugin.ts | 27 ++-- 4 files changed, 147 insertions(+), 105 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/implementation.ts b/packages/progressive-profiling-nodejs/src/implementation.ts index 3b75ac4..f75770f 100644 --- a/packages/progressive-profiling-nodejs/src/implementation.ts +++ b/packages/progressive-profiling-nodejs/src/implementation.ts @@ -44,7 +44,7 @@ export class Implementation { const implementation = Implementation.getInstanceOrThrow(); const userMetadata = await implementation.metadata.get(userId); return implementation.existingSections.every( - (section) => userMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false, + (section) => userMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false ); }, }); @@ -52,9 +52,15 @@ export class Implementation { defaultStorageHandlerGetFields = async function ( this: Implementation, - pluginFormFields: (Pick & { sectionId: string })[], - session: SessionContainerInterface, - userContext?: Record, + { + pluginFormFields, + session, + userContext, + }: { + pluginFormFields: (Pick & { sectionId: string })[]; + session: SessionContainerInterface; + userContext?: Record; + } ): Promise { const metadata = pluginUserMetadata<{ profile: Record }>(METADATA_PROFILE_KEY); @@ -72,10 +78,17 @@ export class Implementation { defaultStorageHandlerSetFields = async function ( this: Implementation, - pluginFormFields: (Pick & { sectionId: string })[], - formData: ProfileFormData, - session: SessionContainerInterface, - userContext?: Record, + { + pluginFormFields, + data, + session, + userContext, + }: { + pluginFormFields: (Pick & { sectionId: string })[]; + data: ProfileFormData; + session: SessionContainerInterface; + userContext?: Record; + } ): Promise { const metadata = pluginUserMetadata<{ profile: Record }>(METADATA_PROFILE_KEY); @@ -85,14 +98,14 @@ export class Implementation { const profile = pluginFormFields.reduce( (acc, field) => { - const newValue = formData.find((d) => d.fieldId === field.id)?.value; + const newValue = data.find((d) => d.fieldId === field.id)?.value; const existingValue = existingProfile?.[field.id]; return { ...acc, [field.id]: newValue ?? existingValue ?? field.defaultValue, }; }, - { ...existingProfile }, + { ...existingProfile } ); await metadata.set( @@ -103,7 +116,7 @@ export class Implementation { ...profile, }, }, - userContext, + userContext ); }; @@ -113,7 +126,7 @@ export class Implementation { const existingSection = this.existingSections.find((s) => s.id === section.id); if (existingSection) { logDebugMessage( - `Profile plugin section with id "${section.id}" already registered by "${existingSection.storageHandlerId}". Skipping...`, + `Profile plugin section with id "${section.id}" already registered by "${existingSection.storageHandlerId}". Skipping...` ); return false; } @@ -137,22 +150,19 @@ export class Implementation { getAllSections = function ( this: Implementation, // eslint-disable-next-line @typescript-eslint/no-unused-vars - session?: SessionContainerInterface, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - userContext?: Record, + input: { session: SessionContainerInterface; userContext?: Record } ) { return this.existingSections; }; getSessionUserSections = async function ( this: Implementation, - session: SessionContainerInterface, - userContext?: Record, + { session, userContext }: { session: SessionContainerInterface; userContext?: Record } ) { const userMetadata = await this.metadata.get(session.getUserId(userContext), userContext); // map the sections to a json serializable value - const sections = this.getAllSections(session, userContext).map((section) => ({ + const sections = this.getAllSections({ session, userContext }).map((section) => ({ id: section.id, label: section.label, description: section.description, @@ -176,42 +186,46 @@ export class Implementation { setSectionValues = async function ( this: Implementation, - session: SessionContainerInterface, - data: ProfileFormData, - userContext?: Record, + { + data, + session, + userContext, + }: { session: SessionContainerInterface; data: ProfileFormData; userContext?: Record } ) { const userId = session.getUserId(userContext); - const sections = this.getAllSections(session, userContext); + const sections = this.getAllSections({ session, userContext }); const sectionsById = indexBy(sections, "id"); const sectionIdToStorageHandlerIdMap = mapBy(sections, "id", (section) => section.storageHandlerId); const dataBySectionId = groupBy(data, "sectionId"); const dataByStorageHandlerId = groupBy(data, (row) => sectionIdToStorageHandlerIdMap[row.sectionId]); // validate the data - const validationErrors = data.reduce( - (acc, row) => { - const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); - if (!field) { - return [ - ...acc, - { - id: row.fieldId, - error: `Field with id "${row.fieldId}" not found`, - }, - ]; - } + const validationErrors = data.reduce((acc, row) => { + const field = sectionsById[row.sectionId]?.fields.find((f) => f.id === row.fieldId); + if (!field) { + return [ + ...acc, + { + id: row.fieldId, + error: `Field with id "${row.fieldId}" not found`, + }, + ]; + } - const fieldError = this.validateField(session, field, row.value, userContext); - if (fieldError) { - const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; - return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; - } + const fieldError = this.validateField({ + field, + value: row.value, + session, + userContext, + }); + if (fieldError) { + const fieldErrors = Array.isArray(fieldError) ? fieldError : [fieldError]; + return [...acc, ...fieldErrors.map((error) => ({ id: field.id, error }))]; + } - return acc; - }, - [] as { id: string; error: string }[], - ); + return acc; + }, [] as { id: string; error: string }[]); logDebugMessage(`Validated data. ${validationErrors.length} errors found.`); @@ -243,7 +257,12 @@ export class Implementation { const sectionsCompleted: Record = {}; for (const section of sectionsToUpdate) { const sectionData = updatedData.filter((d) => d.sectionId === section.id); - sectionsCompleted[section.id] = await this.isSectionCompleted(session, section, sectionData, userContext); + sectionsCompleted[section.id] = await this.isSectionCompleted({ + section, + data: sectionData, + session, + userContext, + }); } logDebugMessage(`Sections completed: ${JSON.stringify(sectionsCompleted)}`); @@ -263,8 +282,8 @@ export class Implementation { // refresh the claim to make sure the frontend has the latest value // but only if all sections are completed - const allSectionsCompleted = this.getAllSections(session, userContext).every( - (section) => newUserMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false, + const allSectionsCompleted = this.getAllSections({ session, userContext }).every( + (section) => newUserMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false ); if (allSectionsCompleted) { await session.fetchAndSetClaim(Implementation.ProgressiveProfilingCompletedClaim, userContext); @@ -275,10 +294,9 @@ export class Implementation { getSectionValues = async function ( this: Implementation, - session: SessionContainerInterface, - userContext?: Record, + { session, userContext }: { session: SessionContainerInterface; userContext?: Record } ) { - const sections = this.getAllSections(session, userContext); + const sections = this.getAllSections({ session, userContext }); const sectionsByStorageHandlerId = indexBy(sections, "storageHandlerId"); const data: ProfileFormData = []; @@ -297,11 +315,15 @@ export class Implementation { validateField = function ( this: Implementation, - session: SessionContainerInterface, - field: FormField, - value: FormFieldValue, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - userContext?: Record, + { + field, + value, + }: { + field: FormField; + value: FormFieldValue; + session: SessionContainerInterface; + userContext?: Record; + } ): string | string[] | undefined { if (field.required && (value === undefined || (typeof value === "string" && value.trim() === ""))) { return `The "${field.label}" field is required`; @@ -312,14 +334,25 @@ export class Implementation { isSectionCompleted = async function ( this: Implementation, - session: SessionContainerInterface, - section: FormSection, - data: ProfileFormData, - userContext?: Record, + { + data, + section, + ...rest + }: { + section: FormSection; + data: ProfileFormData; + session: SessionContainerInterface; + userContext?: Record; + } ) { const valuesByFieldId = mapBy(data, "fieldId", (row) => row.value); return section.fields.every( - (field) => this.validateField(session, field, valuesByFieldId[field.id], userContext) === undefined, + (field) => + this.validateField({ + field, + value: valuesByFieldId[field.id], + ...rest, + }) === undefined ); }; } diff --git a/packages/progressive-profiling-nodejs/src/index.ts b/packages/progressive-profiling-nodejs/src/index.ts index 45b900d..c46f5fb 100644 --- a/packages/progressive-profiling-nodejs/src/index.ts +++ b/packages/progressive-profiling-nodejs/src/index.ts @@ -7,24 +7,24 @@ import { RegisterSections } from "./types"; export type { RegisterSections as RegisterSection } from "./types"; -const getSectionValues = (session: SessionContainerInterface, userContext?: Record) => { - return Implementation.getInstanceOrThrow().getSectionValues(session, userContext); +const getSectionValues = (input: { session: SessionContainerInterface; userContext?: Record }) => { + return Implementation.getInstanceOrThrow().getSectionValues(input); }; -const setSectionValues = ( - session: SessionContainerInterface, - profile: ProfileFormData, - userContext?: Record -) => { - return Implementation.getInstanceOrThrow().setSectionValues(session, profile, userContext); +const setSectionValues = (input: { + session: SessionContainerInterface; + data: ProfileFormData; + userContext?: Record; +}) => { + return Implementation.getInstanceOrThrow().setSectionValues(input); }; const registerSections = (payload: Parameters[0]) => { return Implementation.getInstanceOrThrow().registerSections(payload); }; -const getAllSections = () => { - return Implementation.getInstanceOrThrow().getAllSections(); +const getAllSections = (input: { session: SessionContainerInterface; userContext?: Record }) => { + return Implementation.getInstanceOrThrow().getAllSections(input); }; export { init, PLUGIN_ID, PLUGIN_VERSION, getSectionValues, setSectionValues, registerSections, getAllSections }; diff --git a/packages/progressive-profiling-nodejs/src/plugin.test.ts b/packages/progressive-profiling-nodejs/src/plugin.test.ts index a203e21..fd0a554 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.test.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.test.ts @@ -2,7 +2,7 @@ import express from "express"; import crypto from "node:crypto"; import { describe, it, expect, afterEach, beforeEach } from "vitest"; -import { FormSection, SuperTokensPluginProfileProgressiveProfilingConfig } from "./types"; +import { FormSection } from "./types"; import { registerSections, init, getSectionValues, setSectionValues, getAllSections } from "./index"; import { HANDLE_BASE_PATH } from "./constants"; @@ -275,7 +275,7 @@ describe("progressive-profiling-nodejs", () => { }), }); - const sectionValues = await getSectionValues(session); + const sectionValues = await getSectionValues({ session }); expect(sectionValues.status).toBe("OK"); expect(sectionValues.data).toEqual( testSections @@ -318,7 +318,7 @@ describe("progressive-profiling-nodejs", () => { }), }); - const sectionValues = await getSectionValues(session); + const sectionValues = await getSectionValues({ session }); expect(sectionValues.status).toBe("OK"); expect(sectionValues.data).toEqual( testSections @@ -372,7 +372,7 @@ describe("progressive-profiling-nodejs", () => { }), }); - const sectionValues = await getSectionValues(session); + const sectionValues = await getSectionValues({ session }); expect(sectionValues.status).toBe("OK"); expect(sectionValues.data).toEqual([ ...testSections @@ -394,9 +394,9 @@ describe("progressive-profiling-nodejs", () => { SuperTokens.convertToRecipeUserId(user.id) ); - await setSectionValues( + await setSectionValues({ session, - testSections + data: testSections .map((section) => section.fields.map((field) => ({ sectionId: section.id, @@ -404,8 +404,8 @@ describe("progressive-profiling-nodejs", () => { value: "value", })) ) - .flat() - ); + .flat(), + }); const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { method: "GET", @@ -440,9 +440,9 @@ describe("progressive-profiling-nodejs", () => { SuperTokens.convertToRecipeUserId(user.id) ); - await setSectionValues( + await setSectionValues({ session, - testSections + data: testSections .map((section) => section.fields.map((field) => ({ sectionId: section.id, @@ -450,8 +450,8 @@ describe("progressive-profiling-nodejs", () => { value: "value", })) ) - .flat() - ); + .flat(), + }); const response = await fetch(`http://localhost:${testPORT}${HANDLE_BASE_PATH}/profile`, { method: "GET", @@ -515,7 +515,7 @@ describe("progressive-profiling-nodejs", () => { sections: testSections, override: (oI) => ({ ...oI, - validateField: (session, field, value, userContext) => { + validateField: () => { return "TestInvalid"; }, }), @@ -576,10 +576,11 @@ describe("progressive-profiling-nodejs", () => { const { user } = await setup({ sections: testSections, }); - - const sections = await getAllSections(); - console.log(sections); - console.log(testSections); + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + const sections = await getAllSections({ session }); expect(sections).toEqual( testSections.map((section) => ({ ...section, completed: undefined, storageHandlerId: "default" })) ); @@ -591,17 +592,20 @@ describe("progressive-profiling-nodejs", () => { override: (oI) => ({ ...oI, getAllSections: () => - Promise.resolve( - testSections.map((section) => ({ - ...section, - completed: undefined, - storageHandlerId: "defaultOverride", - })) - ), + testSections.map((section) => ({ + ...section, + completed: undefined, + storageHandlerId: "defaultOverride", + })), }), }); - const sections = await getAllSections(); + const session = await Session.createNewSessionWithoutRequestResponse( + "public", + SuperTokens.convertToRecipeUserId(user.id) + ); + + const sections = getAllSections({ session }); expect(sections).toEqual( testSections.map((section) => ({ ...section, completed: undefined, storageHandlerId: "defaultOverride" })) ); diff --git a/packages/progressive-profiling-nodejs/src/plugin.ts b/packages/progressive-profiling-nodejs/src/plugin.ts index f6498aa..9d755b1 100644 --- a/packages/progressive-profiling-nodejs/src/plugin.ts +++ b/packages/progressive-profiling-nodejs/src/plugin.ts @@ -32,7 +32,7 @@ export const init = createPluginInitFunction< id: field.id, defaultValue: field.defaultValue, sectionId: section.id, - })) + })), ) .flat(); @@ -40,9 +40,14 @@ export const init = createPluginInitFunction< storageHandlerId: "default", sections: pluginConfig.sections, set: (data, session, userContext) => - implementation.defaultStorageHandlerSetFields(defaultFields, data, session, userContext), + implementation.defaultStorageHandlerSetFields({ + pluginFormFields: defaultFields, + data, + session, + userContext, + }), get: (session, userContext) => - implementation.defaultStorageHandlerGetFields(defaultFields, session, userContext), + implementation.defaultStorageHandlerGetFields({ pluginFormFields: defaultFields, session, userContext }), }); } @@ -66,7 +71,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key + (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -75,7 +80,7 @@ export const init = createPluginInitFunction< throw new Error("Session not found"); } - return implementation.getSessionUserSections(session, userContext); + return implementation.getSessionUserSections({ session, userContext }); }), }, { @@ -86,7 +91,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key + (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -97,7 +102,7 @@ export const init = createPluginInitFunction< const payload: { data: ProfileFormData } = await req.getJSONBody(); - return implementation.setSectionValues(session, payload.data, userContext); + return implementation.setSectionValues({ data: payload.data, session, userContext }); }), }, { @@ -108,7 +113,7 @@ export const init = createPluginInitFunction< overrideGlobalClaimValidators: (globalValidators) => { // we should not check if the profile is completed here, because we want to allow users to access the profile page even if they haven't completed the profile return globalValidators.filter( - (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key + (validator) => validator.id !== Implementation.ProgressiveProfilingCompletedClaim.key, ); }, }, @@ -117,7 +122,7 @@ export const init = createPluginInitFunction< throw new Error("Session not found"); } - return implementation.getSectionValues(session, userContext); + return implementation.getSectionValues({ session, userContext }); }), }, ], @@ -142,7 +147,7 @@ export const init = createPluginInitFunction< input.recipeUserId, input.tenantId, input.accessTokenPayload, - input.userContext + input.userContext, )), }; @@ -172,5 +177,5 @@ export const init = createPluginInitFunction< completed: undefined, // make sure the sections are not marked as completed by default })) ?? DEFAULT_SECTIONS, }; - } + }, ); From bad25cb375cd4027097e0bbb19016e512cd57292 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 24 Sep 2025 18:14:24 +0300 Subject: [PATCH 44/46] make claim checking and section completion storing overridable --- .../src/implementation.ts | 82 +++++++++++++------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/implementation.ts b/packages/progressive-profiling-nodejs/src/implementation.ts index f75770f..7a02370 100644 --- a/packages/progressive-profiling-nodejs/src/implementation.ts +++ b/packages/progressive-profiling-nodejs/src/implementation.ts @@ -40,12 +40,18 @@ export class Implementation { constructor() { Implementation.ProgressiveProfilingCompletedClaim = new BooleanClaim({ key: `${PLUGIN_ID}-completed`, - fetchValue: async (userId) => { - const implementation = Implementation.getInstanceOrThrow(); - const userMetadata = await implementation.metadata.get(userId); - return implementation.existingSections.every( - (section) => userMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false - ); + fetchValue: async (userId, recipeUserId, tenantId, payload, userContext) => { + // the plugin caches the completion status of each section + // this is done because of multiple reasons: + // - performace: avoid calling the storage handlers every time we need to check the claim - they can be anything - a databse, another service, a file, etc + // - data scoping: the completion of a section is data specific to the plugin so it makes sense to be handled in the plugin. checking the section completion directly in the claim would make the plugin dependent on the storage handlers. + // - user management: it allows the maintainers/developers to use the dashboard to manage and view the completion status of each section + return Implementation.getInstanceOrThrow().areSectionsCompleted({ + userId, + recipeUserId: recipeUserId.getAsString(), + tenantId, + userContext, + }); }, }); } @@ -257,7 +263,7 @@ export class Implementation { const sectionsCompleted: Record = {}; for (const section of sectionsToUpdate) { const sectionData = updatedData.filter((d) => d.sectionId === section.id); - sectionsCompleted[section.id] = await this.isSectionCompleted({ + sectionsCompleted[section.id] = await this.isSectionValid({ section, data: sectionData, session, @@ -266,25 +272,16 @@ export class Implementation { } logDebugMessage(`Sections completed: ${JSON.stringify(sectionsCompleted)}`); - // update the user metadata with the new sections completed status - const userMetadata = await this.metadata.get(userId, userContext); - const newUserMetadata = { - ...userMetadata, - profileConfig: { - ...userMetadata?.profileConfig, - sectionsCompleted: { - ...(userMetadata?.profileConfig?.sectionsCompleted ?? {}), - ...sectionsCompleted, - }, - }, - }; - await this.metadata.set(userId, newUserMetadata, userContext); + await this.storeCompletedSections({ sectionsCompleted, userId, session, userContext }); // refresh the claim to make sure the frontend has the latest value // but only if all sections are completed - const allSectionsCompleted = this.getAllSections({ session, userContext }).every( - (section) => newUserMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false - ); + const allSectionsCompleted = await this.areSectionsCompleted({ + userId, + userContext, + recipeUserId: session.getRecipeUserId().getAsString(), + tenantId: session.getTenantId(), + }); if (allSectionsCompleted) { await session.fetchAndSetClaim(Implementation.ProgressiveProfilingCompletedClaim, userContext); } @@ -332,7 +329,7 @@ export class Implementation { return undefined; }; - isSectionCompleted = async function ( + isSectionValid = async function ( this: Implementation, { data, @@ -355,4 +352,41 @@ export class Implementation { }) === undefined ); }; + + areSectionsCompleted = async function ( + this: Implementation, + { userId, userContext }: { userId: string; recipeUserId: string; tenantId: string; userContext: any } + ) { + const userMetadata = await this.metadata.get(userId, userContext); + return this.existingSections.every( + (section) => userMetadata?.profileConfig?.sectionsCompleted?.[section.id] ?? false + ); + }; + + storeCompletedSections = async function ( + this: Implementation, + { + sectionsCompleted, + userId, + userContext, + }: { + sectionsCompleted: Record; + userId: string; + session: SessionContainerInterface; + userContext: any; + } + ) { + const userMetadata = await this.metadata.get(userId, userContext); + const newUserMetadata = { + ...userMetadata, + profileConfig: { + ...userMetadata?.profileConfig, + sectionsCompleted: { + ...(userMetadata?.profileConfig?.sectionsCompleted ?? {}), + ...sectionsCompleted, + }, + }, + }; + await this.metadata.set(userId, newUserMetadata, userContext); + }; } From 85f1dfe83044eb249c99ff7a7afaffdc451d3865 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 24 Sep 2025 18:17:28 +0300 Subject: [PATCH 45/46] typo --- .../progressive-profiling-nodejs/src/implementation.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/progressive-profiling-nodejs/src/implementation.ts b/packages/progressive-profiling-nodejs/src/implementation.ts index 7a02370..71ae079 100644 --- a/packages/progressive-profiling-nodejs/src/implementation.ts +++ b/packages/progressive-profiling-nodejs/src/implementation.ts @@ -46,7 +46,7 @@ export class Implementation { // - performace: avoid calling the storage handlers every time we need to check the claim - they can be anything - a databse, another service, a file, etc // - data scoping: the completion of a section is data specific to the plugin so it makes sense to be handled in the plugin. checking the section completion directly in the claim would make the plugin dependent on the storage handlers. // - user management: it allows the maintainers/developers to use the dashboard to manage and view the completion status of each section - return Implementation.getInstanceOrThrow().areSectionsCompleted({ + return Implementation.getInstanceOrThrow().areAllSectionsCompleted({ userId, recipeUserId: recipeUserId.getAsString(), tenantId, @@ -276,7 +276,8 @@ export class Implementation { // refresh the claim to make sure the frontend has the latest value // but only if all sections are completed - const allSectionsCompleted = await this.areSectionsCompleted({ + // make use of areAllSectionsCompleted since this is what is checked in the claim + const allSectionsCompleted = await this.areAllSectionsCompleted({ userId, userContext, recipeUserId: session.getRecipeUserId().getAsString(), @@ -353,7 +354,7 @@ export class Implementation { ); }; - areSectionsCompleted = async function ( + areAllSectionsCompleted = async function ( this: Implementation, { userId, userContext }: { userId: string; recipeUserId: string; tenantId: string; userContext: any } ) { From 5b1aff3cf885869a29135f91b9971903396ccb20 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Thu, 25 Sep 2025 00:44:46 +0200 Subject: [PATCH 46/46] fix: the last section should properly do the success redirection --- .../progressive-profiling-react/package.json | 16 +--------- .../progressive-profiling-form.tsx | 31 ++++++++++++------- .../src/constants.ts | 4 +-- .../progressive-profiling-react/src/index.ts | 1 - .../progressive-profiling-react/src/plugin.ts | 12 +++++-- .../src/progressive-profiling-wrapper.tsx | 18 ++++++++++- .../progressive-profiling-react/src/types.ts | 10 +++++- 7 files changed, 56 insertions(+), 36 deletions(-) diff --git a/packages/progressive-profiling-react/package.json b/packages/progressive-profiling-react/package.json index ea0c547..6013a19 100644 --- a/packages/progressive-profiling-react/package.json +++ b/packages/progressive-profiling-react/package.json @@ -43,19 +43,5 @@ }, "main": "./dist/index.js", "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "dist/index.d.ts", - "default": "dist/index.js" - }, - "./index": { - "types": "dist/index.d.ts", - "default": "dist/index.js" - }, - "./index.js": { - "types": "dist/index.d.ts", - "default": "dist/index.js" - } - } + "types": "./dist/index.d.ts" } diff --git a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx index 040c4ef..956691e 100644 --- a/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx +++ b/packages/progressive-profiling-react/src/components/progressive-profiling-form/progressive-profiling-form.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { usePluginContext } from "../../plugin"; import { FormInputComponentMap } from "../../types"; +import Session from "supertokens-auth-react/recipe/session"; import styles from "./progressive-profiling-form.module.css"; @@ -21,7 +22,7 @@ interface ProgressiveProfilingFormProps { | { status: "ERROR"; message: string } | { status: "INVALID_FIELDS"; errors: { id: string; error: string }[] } >; - onSuccess: (data: ProfileFormData) => Promise; + onSuccess: (data: ProfileFormData) => Promise | void; isLoading: boolean; loadProfile: () => Promise<{ status: "OK"; data: ProfileFormData } | { status: "ERROR"; message: string }>; loadSections: () => Promise<{ status: "OK"; data: FormSection[] } | { status: "ERROR"; message: string }>; @@ -38,7 +39,7 @@ export const ProgressiveProfilingForm = ({ loadSections, ...props }: ProgressiveProfilingFormProps) => { - const { t, pluginConfig } = usePluginContext(); + const { t, pluginConfig, ProgressiveProfilingCompletedClaim } = usePluginContext(); const [fieldErrors, setFieldErrors] = useState>({}); const sections = useMemo(() => { @@ -137,7 +138,12 @@ export const ProgressiveProfilingForm = ({ return; } - if (currentSection.id !== "profile-end" && currentSection.id !== "profile-start") { + if (currentSection.id === "profile-start") { + moveToSection(activeSectionIndex + 1); + return; + } + + if (currentSection.id !== "profile-end") { // only send the current section fields const sectionData = currentSection.fields.map((field) => { return { sectionId: currentSection.id, fieldId: field.id, value: profileDetails[field.id] }; @@ -147,21 +153,22 @@ export const ProgressiveProfilingForm = ({ setFieldErrors(groupBy(result.errors, "id")); throw new Error("Some fields are invalid"); } else if (result.status === "OK") { - moveToSection(activeSectionIndex + 1); - // load the sections to get the updated section states (it's fine to be deferred) - loadSections(); + if (!isLastSection) { + moveToSection(activeSectionIndex + 1); + // load the sections to get the updated section states (it's fine to be deferred) + void loadSections(); + return; + } } else { throw new Error("Could not save the details"); } } - if (currentSection.id === "profile-start") { - moveToSection(activeSectionIndex + 1); - return; - } - if (currentSection.id === "profile-end" || isLastSection) { - const isComplete = formSections.every((section) => section.completed); + const claimValidationErrors = await Session.validateClaims({ + overrideGlobalClaimValidators: () => [ProgressiveProfilingCompletedClaim.validators.isTrue()], + }); + const isComplete = claimValidationErrors.length === 0; if (isComplete) { const data: ProfileFormData = Object.entries(profileDetails).map(([key, value]) => { const sectionId = sections.find((section) => section.fields.some((field) => field.id === key))?.id; diff --git a/packages/progressive-profiling-react/src/constants.ts b/packages/progressive-profiling-react/src/constants.ts index fc3d17e..e7faf50 100644 --- a/packages/progressive-profiling-react/src/constants.ts +++ b/packages/progressive-profiling-react/src/constants.ts @@ -39,8 +39,6 @@ export const DEFAULT_FIELD_TYPE_COMPONENT_MAP: FormInputComponentMap = { export const DEFAULT_REQUIRE_SETUP = true; export const DEFAULT_SETUP_PAGE_PATH = "/user/setup"; -export const DEFAULT_ON_SUCCESS = async () => { - window.location.href = "/"; -}; + export const DEFAULT_SHOW_START_SECTION = true; export const DEFAULT_SHOW_END_SECTION = true; diff --git a/packages/progressive-profiling-react/src/index.ts b/packages/progressive-profiling-react/src/index.ts index 12b5f17..a507ff5 100644 --- a/packages/progressive-profiling-react/src/index.ts +++ b/packages/progressive-profiling-react/src/index.ts @@ -2,7 +2,6 @@ import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; import { init, usePluginContext } from "./plugin"; import { ProgressiveProfilingWrapper } from "./progressive-profiling-wrapper"; -export { init, usePluginContext, PLUGIN_ID, PLUGIN_VERSION, ProgressiveProfilingWrapper as UserProfileWrapper }; export default { init, usePluginContext, diff --git a/packages/progressive-profiling-react/src/plugin.ts b/packages/progressive-profiling-react/src/plugin.ts index e7928b0..6a1e258 100644 --- a/packages/progressive-profiling-react/src/plugin.ts +++ b/packages/progressive-profiling-react/src/plugin.ts @@ -7,9 +7,10 @@ import { getApi } from "./api"; import { API_PATH, DEFAULT_FIELD_TYPE_COMPONENT_MAP, - DEFAULT_ON_SUCCESS, DEFAULT_REQUIRE_SETUP, DEFAULT_SETUP_PAGE_PATH, + DEFAULT_SHOW_END_SECTION, + DEFAULT_SHOW_START_SECTION, PLUGIN_ID, } from "./constants"; import { enableDebugLogs } from "./logger"; @@ -17,6 +18,7 @@ import { ProgressiveProfilingSetupPage } from "./progressive-profiling-setup-pag import { defaultTranslationsProgressiveProfiling } from "./translations"; import { SuperTokensPluginProfileProgressiveProfilingConfig, + SuperTokensPluginProfileProgressiveProfilingNormalisedConfig, SuperTokensPluginProfileProgressiveProfilingImplementation, FormInputComponentMap, TranslationKeys, @@ -28,6 +30,7 @@ const { usePluginContext, setContext } = buildContext<{ querier: ReturnType; api: ReturnType; t: (key: TranslationKeys, replacements?: Record) => string; + ProgressiveProfilingCompletedClaim: BooleanClaim; }>(); export { usePluginContext }; @@ -35,7 +38,7 @@ export const init = createPluginInitFunction< SuperTokensPlugin, SuperTokensPluginProfileProgressiveProfilingConfig, SuperTokensPluginProfileProgressiveProfilingImplementation, - Required + SuperTokensPluginProfileProgressiveProfilingNormalisedConfig >( (pluginConfig, implementation) => { const componentMap = implementation.componentMap(); @@ -65,6 +68,7 @@ export const init = createPluginInitFunction< querier, api, t, + ProgressiveProfilingCompletedClaim, }); }, routeHandlers: () => { @@ -101,8 +105,10 @@ export const init = createPluginInitFunction< componentMap: () => DEFAULT_FIELD_TYPE_COMPONENT_MAP, }, (config) => ({ - onSuccess: config.onSuccess ?? DEFAULT_ON_SUCCESS, requireSetup: config.requireSetup ?? DEFAULT_REQUIRE_SETUP, setupPagePath: config.setupPagePath ?? DEFAULT_SETUP_PAGE_PATH, + showStartSection: config.showStartSection ?? DEFAULT_SHOW_START_SECTION, + showEndSection: config.showEndSection ?? DEFAULT_SHOW_END_SECTION, + onSuccess: config.onSuccess, }), ); diff --git a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx index 782913d..f9dfa81 100644 --- a/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx +++ b/packages/progressive-profiling-react/src/progressive-profiling-wrapper.tsx @@ -4,11 +4,14 @@ import { useCallback, useEffect, useState } from "react"; import { ProgressiveProfilingForm } from "./components"; import { usePluginContext } from "./plugin"; +import { AuthPage } from "supertokens-auth-react/ui"; export const ProgressiveProfilingWrapper = () => { const { api, componentMap, t, pluginConfig } = usePluginContext(); const [isLoading, setIsLoading] = useState(true); + const [isSuccess, setIsSuccess] = useState(false); + const [sections, setSections] = useState([]); const [data, setData] = useState([]); @@ -56,6 +59,11 @@ export const ProgressiveProfilingWrapper = () => { return response; }, []); + const onSuccess = useCallback(async () => { + await pluginConfig.onSuccess?.(data); + setIsSuccess(true); + }, []); + useEffect(() => { loadSections(); loadProfile(); @@ -66,8 +74,16 @@ export const ProgressiveProfilingWrapper = () => { return null; } + // The loading string below should never actually appear on screen, but we need to provide a component to prevent the AuthPage from loading unnecessary components. return ( + {isSuccess && ( +
+ + <>Loading... + +
+ )} { isLoading={isLoading} loadProfile={loadProfile} loadSections={loadSections} - onSuccess={pluginConfig.onSuccess} + onSuccess={onSuccess} componentMap={componentMap} /> diff --git a/packages/progressive-profiling-react/src/types.ts b/packages/progressive-profiling-react/src/types.ts index f5c5e3c..3e60855 100644 --- a/packages/progressive-profiling-react/src/types.ts +++ b/packages/progressive-profiling-react/src/types.ts @@ -8,7 +8,15 @@ export type SuperTokensPluginProfileProgressiveProfilingConfig = { requireSetup?: boolean; showStartSection?: boolean; showEndSection?: boolean; - onSuccess: (data: ProfileFormData) => Promise; + onSuccess?: (data: ProfileFormData) => Promise | undefined; +}; + +export type SuperTokensPluginProfileProgressiveProfilingNormalisedConfig = { + setupPagePath: string; + requireSetup: boolean; + showStartSection: boolean; + showEndSection: boolean; + onSuccess?: (data: ProfileFormData) => Promise | undefined; }; export type SuperTokensPluginProfileProgressiveProfilingImplementation = {